Merge remote-tracking branch 'origin/4.1' into integration

main
Pau Ferrer Ocaña 2022-05-26 17:05:50 +02:00
commit d897ccffbd
69 changed files with 7528 additions and 289 deletions

View File

@ -0,0 +1,65 @@
name: Behat tests
on:
workflow_dispatch:
inputs:
tags:
description: 'Execute tags'
required: true
default: '~@performance'
moodle_branch:
description: 'Moodle branch'
required: true
default: 'master'
moodle_repository:
description: 'Moodle repository'
required: true
default: 'https://github.com/moodle/moodle'
jobs:
behat:
runs-on: ubuntu-latest
env:
MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: 7.3
steps:
- uses: actions/checkout@v2
- id: nvmrc
uses: browniebroke/read-nvmrc-action@v1
- uses: actions/setup-node@v1
with:
node-version: '${{ steps.nvmrc.outputs.node_version }}'
- name: Additional checkouts
run: |
git clone --branch ${{ github.event.inputs.moodle_branch }} --depth 1 ${{ github.event.inputs.moodle_repository }} $GITHUB_WORKSPACE/moodle
git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
- name: Install npm packages
run: |
npm install -g npm@7
npm ci --no-audit
- name: Generate Behat tests plugin
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
npx gulp behat
- name: Configure & launch Moodle with Docker
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php
sed -i "61i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db
- name: Compile & launch production app with Docker
run: |
docker build -t moodlehq/moodleapp:behat .
docker run -d --rm --name moodleapp moodlehq/moodleapp:behat
docker network connect moodle-docker_default moodleapp --alias moodleapp
- name: Init Behat
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php"
- name: Run Behat tests
run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&${{ github.event.inputs.tags }}' --auto-rerun"

View File

@ -1,6 +1,6 @@
name: Performance name: Performance
on: [push, pull_request] on: [ pull_request, workflow_dispatch ]
jobs: jobs:
performance: performance:
@ -19,7 +19,6 @@ jobs:
- name: Additional checkouts - name: Additional checkouts
run: | run: |
git clone --branch master --depth 1 https://github.com/moodle/moodle $GITHUB_WORKSPACE/moodle git clone --branch master --depth 1 https://github.com/moodle/moodle $GITHUB_WORKSPACE/moodle
git clone --branch integration --depth 1 https://github.com/moodlehq/moodle-local_moodlemobileapp $GITHUB_WORKSPACE/moodle/local/moodlemobileapp
git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
- name: Install npm packages - name: Install npm packages
run: | run: |

View File

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<widget android-versionCode="40100" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.0.1.0" version="4.0.1" versionCode="40100" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0"> <widget android-versionCode="41000" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.1.0.0" version="4.1.0-dev" versionCode="41000" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>Moodle</name> <name>Moodle</name>
<description>Moodle official app</description> <description>Moodle official app</description>
<author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author> <author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author>
@ -27,7 +27,7 @@
<preference name="UIWebViewBounce" value="false" /> <preference name="UIWebViewBounce" value="false" />
<preference name="DisallowOverscroll" value="true" /> <preference name="DisallowOverscroll" value="true" />
<preference name="prerendered-icon" value="true" /> <preference name="prerendered-icon" value="true" />
<preference name="AppendUserAgent" value="MoodleMobile 4.0.1 (40100)" /> <preference name="AppendUserAgent" value="MoodleMobile 4.1.0-dev (41000)" />
<preference name="BackupWebStorage" value="none" /> <preference name="BackupWebStorage" value="none" />
<preference name="ScrollEnabled" value="false" /> <preference name="ScrollEnabled" value="false" />
<preference name="KeyboardDisplayRequiresUserAction" value="false" /> <preference name="KeyboardDisplayRequiresUserAction" value="false" />
@ -251,7 +251,7 @@
<true /> <true />
</edit-config> </edit-config>
<edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString"> <edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString">
<string>4.0.1</string> <string>4.1.0</string>
</edit-config> </edit-config>
<edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations"> <edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations">
<array> <array>

View File

@ -71,5 +71,5 @@ gulp.task('watch', () => {
}); });
gulp.task('watch-behat', () => { gulp.task('watch-behat', () => {
gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat')); gulp.watch(['./src/**/*.feature', './local-moodleappbehat'], { interval: 500 }, gulp.parallel('behat'));
}); });

View File

@ -0,0 +1,42 @@
<?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/>.
namespace local_moodleappbehat\output;
defined('MOODLE_INTERNAL') || die();
class mobile {
/**
* Render index page.
*
* @return array View data.
*/
public static function view_index() {
$templates = [
[
'id' => 'main',
'html' => '<h1 class="text-center">Hello<span id="username"></span>!</h1>',
],
];
$javascript = file_get_contents(__DIR__ . '/../../js/mobile/index.js');
return compact('templates', 'javascript');
}
}

View File

@ -0,0 +1,34 @@
<?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/>.
$addons = [
'local_moodleappbehat' => [
'handlers' => [
'index' => [
'delegate' => 'CoreMainMenuDelegate',
'method' => 'view_index',
'displaydata' => [
'title' => 'pluginname',
'icon' => 'language',
],
],
],
'lang' => [
['pluginname', 'local_moodleappbehat'],
],
],
];

View File

@ -0,0 +1,5 @@
this.CoreSitesProvider.getSite().then(site => {
const username = site.infos.username;
document.getElementById('username').innerText = `, ${username}`;
});

View File

@ -0,0 +1,807 @@
<?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/>.
// 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');
require_once(__DIR__ . '/behat_app_helper.php');
use Behat\Gherkin\Node\TableNode;
use Behat\Mink\Exception\DriverException;
use Behat\Mink\Exception\ExpectationException;
/**
* Moodle 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_app_helper {
/** @var string URL for running Ionic server */
protected $ionicurl = '';
/** @var array Config overrides */
protected $appconfig = ['disableUserTours' => true];
protected $windowsize = '360x720';
/**
* Opens the Moodle App in the browser and optionally logs in.
*
* @When I enter the app
* @Given I entered the app as :username
* @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(string $username = null) {
$this->i_launch_the_app();
if (!is_null($username)) {
$this->open_moodleapp_custom_login_url($username);
return;
}
$this->enter_site();
}
/**
* Check whether the current page is the login form.
*/
protected function is_in_login_page(): bool {
$page = $this->getSession()->getPage();
$logininput = $page->find('xpath', '//page-core-login-site//input[@name="url"]');
return !is_null($logininput);
}
/**
* Opens the Moodle App in the browser.
*
* @When I launch the app :runtime
* @When I launch 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_launch_the_app(string $runtime = '') {
// Check the app tag was set.
if (!$this->has_tag('app')) {
throw new DriverException('Requires @app tag on scenario or feature.');
}
// Go to page and prepare browser for app.
$this->prepare_browser(['skiponboarding' => empty($runtime)]);
}
/**
* @Then I wait the app to restart
*/
public function i_wait_the_app_to_restart() {
// Wait window to reload.
$this->spin(function() {
$result = $this->evaluate_script("return !window.behat;");
if (!$result) {
throw new DriverException('Window is not reloading properly.');
}
return true;
});
// Prepare testing runtime again.
$this->prepare_browser(['restart' => false]);
}
/**
* Finds elements in the app.
*
* @Then /^I should( not)? find (".+")( inside the .+)? in the app$/
*/
public function i_find_in_the_app(bool $not, string $locator, string $containerName = '') {
$locator = $this->parse_element_locator($locator);
if (!empty($containerName)) {
preg_match('/^ inside the (.+)$/', $containerName, $matches);
$containerName = $matches[1];
}
$containerName = json_encode($containerName);
$this->spin(function() use ($not, $locator, $containerName) {
$result = $this->evaluate_script("return window.behat.find($locator, $containerName);");
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();
}
/**
* Scroll to an element in the app.
*
* @When /^I scroll to (".+") in the app$/
* @param string $locator
*/
public function i_scroll_to_in_the_app(string $locator) {
$locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator) {
$result = $this->evaluate_script("return window.behat.scrollTo($locator);");
if ($result !== 'OK') {
throw new DriverException('Error finding item - ' . $result);
}
return true;
});
$this->wait_for_pending_js();
// Wait scroll animation to finish.
$this->getSession()->wait(300);
}
/**
* Load more items in a list with an infinite loader.
*
* @When /^I (should not be able to )?load more items in the app$/
* @param bool $not
*/
public function i_load_more_items_in_the_app(bool $not = false) {
$this->spin(function() use ($not) {
$result = $this->evaluate_async_script('return window.behat.loadMoreItems();');
if ($not && $result !== 'ERROR: All items are already loaded.') {
throw new DriverException('It should not have been possible to load more items');
}
if (!$not && $result !== 'OK') {
throw new DriverException('Error loading more items - ' . $result);
}
return true;
});
$this->wait_for_pending_js();
}
/**
* Trigger swipe gesture.
*
* @When /^I swipe to the (left|right) in the app$/
* @param string $direction
*/
public function i_swipe_in_the_app(string $direction) {
$method = 'swipe' . ucwords($direction);
$this->evaluate_script("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()");
$this->wait_for_pending_js();
// Wait swipe animation to finish.
$this->getSession()->wait(300);
}
/**
* Check if elements are selected in the app.
*
* @Then /^(".+") should( not)? be selected in the app$/
* @param string $locator
* @param bool $not
*/
public function be_selected_in_the_app(string $locator, bool $not = false) {
$locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator, $not) {
$result = $this->evaluate_script("return window.behat.isSelected($locator);");
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();
}
/**
* 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_in_the_app('"Log in" near "Forgotten"');
// Wait until the main page appears.
$this->spin(
function($context, $args) {
$mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu');
if ($mainmenu) {
return true;
}
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();
}
/**
* Enter site.
*/
protected function enter_site() {
if (!$this->is_in_login_page()) {
// Already in the site.
return;
}
global $CFG;
$this->i_set_the_field_in_the_app('Your site', $CFG->wwwroot);
$this->i_press_in_the_app('"Connect to your site"');
$this->wait_for_pending_js();
}
/**
* Shortcut to let the user enter a course in the app.
*
* @Given I entered the course :coursename as :username in the app
* @Given I entered the course :coursename in the app
* @param string $coursename Course name
* @throws DriverException If the button push doesn't work
*/
public function i_entered_the_course_in_the_app(string $coursename, ?string $username = null) {
$courseid = $this->get_course_id($coursename);
if (!$courseid) {
throw new DriverException("Course '$coursename' not found");
}
if ($username) {
$this->i_launch_the_app();
$this->open_moodleapp_custom_login_url($username, "/course/view.php?id=$courseid", '//page-core-course-index');
} else {
$this->open_moodleapp_custom_url("/course/view.php?id=$courseid", '//page-core-course-index');
}
}
/**
* User enters a course in the app.
*
* @Given I enter the course :coursename in the app
* @param string $coursename Course name
* @throws DriverException If the button push doesn't work
*/
public function i_enter_the_course_in_the_app(string $coursename, ?string $username = null) {
if (!is_null($username)) {
$this->i_enter_the_app();
$this->login($username);
}
$mycoursesfound = $this->evaluate_script("return window.behat.find({ text: 'My courses', selector: 'ion-tab-button'});");
if ($mycoursesfound !== 'OK') {
// My courses not present enter from Dashboard.
$this->i_press_in_the_app('"Home" "ion-tab-button"');
$this->i_press_in_the_app('"Dashboard"');
$this->i_press_in_the_app('"'.$coursename.'" near "Course overview"');
$this->wait_for_pending_js();
return;
}
$this->i_press_in_the_app('"My courses" "ion-tab-button"');
$this->i_press_in_the_app('"'.$coursename.'"');
$this->wait_for_pending_js();
}
/**
* User enters an activity in a course in the app.
*
* @Given I entered the :activity activity :activityname on course :course as :username in the app
* @Given I entered the :activity activity :activityname on course :course in the app
* @throws DriverException If the button push doesn't work
*/
public function i_enter_the_activity_in_the_app(string $activity, string $activityname, string $coursename, ?string $username = null) {
$cm = $this->get_cm_by_activity_name_and_course($activity, $activityname, $coursename);
if (!$cm) {
throw new DriverException("'$activityname' activity '$activityname' not found");
}
$pageurl = "/mod/$activity/view.php?id={$cm->id}";
if ($username) {
$this->i_launch_the_app();
$this->open_moodleapp_custom_login_url($username, $pageurl);
} else {
$this->open_moodleapp_custom_url($pageurl);
}
}
/**
* Presses standard buttons in the app.
*
* @When /^I press the (back|more menu|page menu|user menu|main 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() 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();
}
/**
* Receives push notifications.
*
* @When /^I receive a push notification in the app for:$/
* @param TableNode $data
*/
public function i_receive_a_push_notification(TableNode $data) {
global $DB, $CFG;
$data = (object) $data->getColumnsHash()[0];
$module = $DB->get_record('course_modules', ['idnumber' => $data->module]);
$discussion = $DB->get_record('forum_discussions', ['name' => $data->discussion]);
$notification = json_encode([
'site' => md5($CFG->behat_wwwroot . $data->username),
'courseid' => $discussion->course,
'moodlecomponent' => 'mod_forum',
'name' => 'posts',
'contexturl' => '',
'notif' => 1,
'customdata' => [
'discussionid' => $discussion->id,
'cmid' => $module->id,
'instance' => $discussion->forum,
],
]);
$this->evaluate_script("return window.pushNotifications.notificationClicked($notification)");
$this->wait_for_pending_js();
}
/**
* Replace arguments from the content in the given activity field.
*
* @Given /^I replace the arguments in "([^"]+)" "([^"]+)"$/
*/
public function i_replace_arguments_in_the_activity(string $idnumber, string $field) {
global $DB;
$coursemodule = $DB->get_record('course_modules', compact('idnumber'));
$module = $DB->get_record('modules', ['id' => $coursemodule->module]);
$activity = $DB->get_record($module->name, ['id' => $coursemodule->instance]);
$DB->update_record($module->name, [
'id' => $coursemodule->instance,
$field => $this->replace_arguments($activity->{$field}),
]);
}
/**
* Opens a custom link.
*
* @Given /^I open a custom link in the app for:$/
* @param TableNode $data
*/
public function i_open_a_custom_link(TableNode $data) {
global $DB;
$data = $data->getColumnsHash()[0];
$title = array_keys($data)[0];
$data = (object) $data;
switch ($title) {
case 'discussion':
$discussion = $DB->get_record('forum_discussions', ['name' => $data->discussion]);
$pageurl = "/mod/forum/discuss.php?d={$discussion->id}";
break;
case 'assign':
case 'bigbluebuttonbn':
case 'book':
case 'chat':
case 'choice':
case 'data':
case 'feedback':
case 'folder':
case 'forum':
case 'glossary':
case 'h5pactivity':
case 'imscp':
case 'label':
case 'lesson':
case 'lti':
case 'page':
case 'quiz':
case 'resource':
case 'scorm':
case 'survey':
case 'url':
case 'wiki':
case 'workshop':
$name = $data->$title;
$module = $DB->get_record($title, ['name' => $name]);
$cm = get_coursemodule_from_instance($title, $module->id);
$pageurl = "/mod/$title/view.php?id={$cm->id}";
break;
default:
throw new DriverException('Invalid custom link title - ' . $title);
}
$this->open_moodleapp_custom_url($pageurl);
}
/**
* Closes a popup by clicking on the 'backdrop' behind it.
*
* @When 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() {
$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();
}
/**
* Override app config.
*
* @Given /^the app has the following config:$/
* @param TableNode $data
*/
public function the_app_has_the_following_config(TableNode $data) {
foreach ($data->getRows() as $configrow) {
$this->appconfig[$configrow[0]] = json_decode($configrow[1]);
}
}
/**
* 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.
*
* @Then /^I press (".+") in the app$/
* @param string $locator Element locator
* @throws DriverException If the press doesn't work
*/
public function i_press_in_the_app(string $locator) {
$locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator) {
$result = $this->evaluate_script("return window.behat.press($locator);");
if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result);
}
return true;
});
$this->wait_for_pending_js();
}
/**
* Select an item from a list of options, such as a radio button.
*
* It may be necessary to use this step instead of "I press..." because radio buttons in Ionic are initialized
* with JavaScript, and clicks may not work until they are initialized properly which may cause flaky tests due
* to race conditions.
*
* @Then /^I (unselect|select) (".+") in the app$/
* @param string $selectedtext
* @param string $locator
* @throws DriverException If the press doesn't work
*/
public function i_select_in_the_app(string $selectedtext, string $locator) {
$selected = $selectedtext === 'select' ? 'YES' : 'NO';
$locator = $this->parse_element_locator($locator);
$this->spin(function() use ($selectedtext, $selected, $locator) {
// Don't do anything if the item is already in the expected state.
$result = $this->evaluate_script("return window.behat.isSelected($locator);");
if ($result === $selected) {
return true;
}
// Press item.
$result = $this->evaluate_script("return window.behat.press($locator);");
if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result);
}
// Check that it worked as expected.
$this->wait_for_pending_js();
$result = $this->evaluate_script("return window.behat.isSelected($locator);");
switch ($result) {
case 'YES':
case 'NO':
if ($result !== $selected) {
throw new ExpectationException("Item wasn't $selectedtext after pressing it", $this->getSession()->getDriver());
}
return true;
default:
throw new DriverException('Error finding item - ' . $result);
}
});
$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 "((?:[^"]|\\")+)" to "((?:[^"]|\\")*)" 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) {
$field = addslashes_js($field);
$value = addslashes_js($value);
$this->spin(function() use ($field, $value) {
$result = $this->evaluate_script("return window.behat.setField(\"$field\", \"$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 "((?:[^"]|\\")+)" 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;
});
}
/**
* Check that the app opened a new browser tab.
*
* @Then /^the app should( not)? have opened a browser tab(?: with url "(?P<pattern>[^"]+)")?$/
* @param bool $not
* @param string $urlpattern
*/
public function the_app_should_have_opened_a_browser_tab(bool $not = false, ?string $urlpattern = null) {
$this->spin(function() use ($not, $urlpattern) {
$windowNames = $this->getSession()->getWindowNames();
$openedbrowsertab = count($windowNames) === 2;
if ((!$not && !$openedbrowsertab) || ($not && $openedbrowsertab && is_null($urlpattern))) {
throw new ExpectationException(
$not
? 'Did not expect the app to have opened a browser tab'
: 'Expected the app to have opened a browser tab',
$this->getSession()->getDriver()
);
}
if (!is_null($urlpattern)) {
$this->getSession()->switchToWindow($windowNames[1]);
$windowurl = $this->getSession()->getCurrentUrl();
$windowhaspattern = preg_match("/$urlpattern/", $windowurl);
$this->getSession()->switchToWindow($windowNames[0]);
if ($not === $windowhaspattern) {
throw new ExpectationException(
$not
? "Did not expect the app to have opened a browser tab with pattern '$urlpattern'"
: "Browser tab url does not match pattern '$urlpattern', it is '$windowurl'",
$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() {
$windowNames = $this->getSession()->getWindowNames();
if (count($windowNames) !== 2) {
throw new DriverException('Expected to see 2 tabs open, not ' . count($windowNames));
}
$this->getSession()->switchToWindow($windowNames[1]);
}
/**
* Force cron tasks instead of waiting for the next scheduled execution.
*
* @When I run cron tasks in the app
*/
public function i_run_cron_tasks_in_the_app() {
$session = $this->getSession();
// Force cron tasks execution and wait until they are completed.
$operationid = random_string();
$session->executeScript(
"cronProvider.forceSyncExecution().then(() => { window['behat_{$operationid}_completed'] = true; });"
);
$this->spin(
function() use ($session, $operationid) {
return $session->evaluateScript("window['behat_{$operationid}_completed'] || false");
},
false,
60,
new ExpectationException('Forced cron tasks in the app took too long to complete', $session)
);
// Trigger Angular change detection.
$this->trigger_angular_change_detection();
}
/**
* Wait until loading has finished.
*
* @When I wait loading to finish in the app
*/
public function i_wait_loading_to_finish_in_the_app() {
$session = $this->getSession();
$this->spin(
function() use ($session) {
$this->trigger_angular_change_detection();
$nodes = $this->find_all('css', 'core-loading ion-spinner');
foreach ($nodes as $node) {
if (!$node->isVisible()) {
continue;
}
return false;
}
return true;
},
false,
60,
new ExpectationException('"Loading took too long to complete', $session)
);
}
/**
* 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));
}
// Make sure the browser tab is selected.
if ($this->getSession()->getWindowName() !== $names[1]) {
$this->getSession()->switchToWindow($names[1]);
}
$this->execute_script('window.close();');
$this->getSession()->switchToWindow($names[0]);
}
/**
* Switch navigator online mode.
*
* @Given /^I switch offline mode to "(true|false)"$/
* @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,698 @@
<?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/>.
// 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;
/**
* Behat app listener.
*/
interface behat_app_listener {
/**
* Called when the app is loaded.
*/
function on_app_load(): void;
/**
* Called before the app is unloaded.
*/
function on_app_unload(): void;
}
/**
* A trait containing functionality used by the behat app context.
*
* @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_helper extends behat_base {
/** @var stdClass Object with data about launched Ionic instance (if any) */
protected static $ionicrunning = null;
/** @var array */
protected static $listeners = [];
/** @var bool Whether the app is running or not */
protected $apprunning = false;
/** @var string */
protected $lmsversion = null;
/**
* Register listener.
*
* @param behat_app_listener $listener Listener.
* @return Closure Unregister function.
*/
public static function listen(behat_app_listener $listener): Closure {
self::$listeners[] = $listener;
return function () use ($listener) {
$index = array_search($listener, self::$listeners);
if ($index !== false) {
array_splice(self::$listeners, $index, 1);
}
};
}
/**
* 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->skip_restricted_tags_scenarios();
$this->check_behat_setup();
$this->fix_moodle_setup();
$this->ionicurl = $this->start_or_reuse_ionic();
}
/**
* 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 Moodle 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);
}
}
/**
* 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(array $options = []) {
$restart = $options['restart'] ?? true;
if ($restart) {
if ($this->apprunning) {
$this->notify_unload();
}
// Restart the browser and set its size.
$this->getSession()->restart();
$this->resize_window($this->windowsize, true);
if (empty($this->ionicurl)) {
$this->ionicurl = $this->start_or_reuse_ionic();
}
// Visit the Ionic URL.
$this->getSession()->visit($this->ionicurl);
$this->notify_load();
$this->apprunning = true;
}
// Wait the application to load.
$this->spin(function($context) {
$title = $context->getSession()->getPage()->find('xpath', '//title');
if ($title) {
$text = $title->getHtml();
if ($text === 'Moodle App') {
return true;
}
}
throw new DriverException('Moodle App not found in browser');
}, false, 60);
try {
// Init Behat JavaScript runtime.
$initOptions = new StdClass();
$initOptions->skipOnBoarding = $options['skiponboarding'] ?? true;
$initOptions->configOverrides = $this->appconfig;
$this->execute_script('window.behatInit(' . json_encode($initOptions) . ');');
} catch (Exception $error) {
throw new DriverException('Moodle App not running or not running on Automated mode.');
}
if ($restart) {
// Assert initial page.
$this->spin(function($context) {
$page = $context->getSession()->getPage();
$element = $page->find('xpath', '//page-core-login-site//input[@name="url"]');
if ($element) {
// Login screen found.
return true;
}
if ($page->find('xpath', '//page-core-mainmenu')) {
// Main menu found.
return true;
}
throw new DriverException('Moodle App not launched properly');
}, false, 60);
}
// Continue only after JS finishes.
$this->wait_for_pending_js();
}
/**
* Parse an element locator string.
*
* @param string $text Element locator string.
* @return JSON of the locator.
*/
public function parse_element_locator(string $text): string {
preg_match(
'/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: (near|within) "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/',
$text,
$matches
);
$locator = [
'text' => str_replace('\\"', '"', $matches[1]),
'selector' => $matches[2] ?? null,
];
if (!empty($matches[3])) {
$locator[$matches[3]] = (object) [
'text' => str_replace('\\"', '"', $matches[4]),
'selector' => $matches[5] ?? null,
];
}
return json_encode((object) $locator);
}
/**
* Replaces $WWWROOT for the url of the Moodle site.
*
* @Transform /^(.*\$WWWROOT.*)$/
* @param string $text Text.
* @return string
*/
public function replace_wwwroot($text) {
global $CFG;
return str_replace('$WWWROOT', $CFG->behat_wwwroot, $text);
}
/**
* Replace arguments with the format "${activity:field}" from a string, where "activity" is
* the idnumber of an activity and "field" is the activity's field to get replacement from.
*
* At the moment, the only field supported is "cmid", the id of the course module for this activity.
*
* @param string $text Original text.
* @return string Text with arguments replaced.
*/
protected function replace_arguments(string $text): string {
global $DB;
preg_match_all("/\\$\\{([^:}]+):([^}]+)\\}/", $text, $matches);
foreach ($matches[0] as $index => $match) {
switch ($matches[2][$index]) {
case 'cmid':
$coursemodule = $DB->get_record('course_modules', ['idnumber' => $matches[1][$index]]);
$text = str_replace($match, $coursemodule->id, $text);
break;
}
}
return $text;
}
/**
* Notify to listeners that the app was just loaded.
*/
protected function notify_load(): void {
foreach (self::$listeners as $listener) {
$listener->on_app_load();
}
}
/**
* Notify to listeners that the app is about to be unloaded.
*/
protected function notify_unload(): void {
foreach (self::$listeners as $listener) {
$listener->on_app_unload();
}
}
/**
* Trigger Angular change detection.
*/
protected function trigger_angular_change_detection() {
$this->getSession()->executeScript('ngZone.run(() => {});');
}
/**
* Evaluate a script that returns a Promise.
*
* @param string $script
* @return mixed Resolved promise result.
*/
protected function evaluate_async_script(string $script) {
$script = preg_replace('/^return\s+/', '', $script);
$script = preg_replace('/;$/', '', $script);
$start = microtime(true);
$promisevariable = 'PROMISE_RESULT_' . time();
$timeout = self::get_timeout();
$this->evaluate_script("Promise.resolve($script)
.then(result => window.$promisevariable = result)
.catch(error => window.$promisevariable = 'Async code rejected: ' + error?.message);");
do {
if (microtime(true) - $start > $timeout) {
throw new DriverException("Async script not resolved after $timeout seconds");
}
usleep(100000);
} while (!$this->evaluate_script("return '$promisevariable' in window;"));
$result = $this->evaluate_script("return window.$promisevariable;");
$this->evaluate_script("delete window.$promisevariable;");
return $result;
}
/**
* Opens a custom URL for automatic login and redirect from the Moodle App (and waits to finish.)
*
* @param string $username Of the user that needs to be logged in.
* @param string $path To redirect the user.
* @param string $successXPath If a path is declared, the XPath of the element to lookat after redirect.
*/
protected function open_moodleapp_custom_login_url($username, $path = '', string $successXPath = '') {
global $CFG, $DB;
require_once($CFG->libdir.'/externallib.php');
require_once($CFG->libdir.'/moodlelib.php');
// Ensure the user exists.
$userid = $DB->get_field('user', 'id', [ 'username' => $username ]);
if (!$userid) {
throw new DriverException("User '$username' not found");
}
// Get or create the user token.
$service = $DB->get_record('external_services', ['shortname' => 'moodle_mobile_app']);
$token_params = [
'userid' => $userid,
'externalserviceid' => $service->id,
];
$usertoken = $DB->get_record('external_tokens', $token_params);
if (!$usertoken) {
$context = context_system::instance();
$token = external_generate_token(EXTERNAL_TOKEN_PERMANENT, $service, $userid, $context);
$token_params['token'] = $token;
$privatetoken = $DB->get_field('external_tokens', 'privatetoken', $token_params);
} else {
$token = $usertoken->token;
$privatetoken = $usertoken->privatetoken;
}
// Generate custom URL.
$parsed_url = parse_url($CFG->behat_wwwroot);
$domain = $parsed_url['host'];
$url = $this->get_mobile_url_scheme() . "://$username@$domain?token=$token&privatetoken=$privatetoken";
if (!empty($path)) {
$url .= '&redirect='.urlencode($CFG->behat_wwwroot.$path);
} else {
$successXPath = '//page-core-mainmenu';
}
$this->handle_url_and_wait_page_to_load($url, $successXPath);
}
/**
* Opens a custom URL on the Moodle App (and waits to finish.)
*
* @param string $path To navigate.
* @param string $successXPath The XPath of the element to lookat after navigation.
*/
protected function open_moodleapp_custom_url(string $path, string $successXPath = '') {
global $CFG;
$urlscheme = $this->get_mobile_url_scheme();
$url = "$urlscheme://link=" . urlencode($CFG->behat_wwwroot.$path);
$this->handle_url_and_wait_page_to_load($url);
}
/**
* Handles the custom URL on the Moodle App (and waits to finish.)
*
* @param string $customurl To navigate.
* @param string $successXPath The XPath of the element to lookat after navigation.
*/
protected function handle_url_and_wait_page_to_load(string $customurl, string $successXPath = '') {
// Instead of using evaluate_async_script, we wait for the path to load.
$this->evaluate_script("return window.behat.handleCustomURL('$customurl')");
$this->wait_for_pending_js();
if (!empty($successXPath)) {
// Wait until the page appears.
$this->spin(
function($context, $args) use ($successXPath) {
$found = $context->getSession()->getPage()->find('xpath', $successXPath);
if ($found) {
return true;
}
throw new DriverException('Moodle App custom URL page not loaded');
}, false, 30);
// Wait for JS to finish as well.
$this->wait_for_pending_js();
}
}
/**
* Returns the current mobile url scheme of the site.
*/
protected function get_mobile_url_scheme() {
$mobilesettings = get_config('tool_mobile');
return !empty($mobilesettings->forcedurlscheme) ? $mobilesettings->forcedurlscheme : 'moodlemobile';
}
/**
* Get a coursemodule from an activity name or idnumber with course.
*
* @param string $activity
* @param string $identifier
* @param string $coursename
* @return cm_info
*/
protected function get_cm_by_activity_name_and_course(string $activity, string $identifier, string $coursename): cm_info {
global $DB;
$courseid = $this->get_course_id($coursename);
if (!$courseid) {
throw new DriverException("Course '$coursename' not found");
}
if ($activity === 'assignment') {
$activity = 'assign';
}
$cmtable = new \core\dml\table('course_modules', 'cm', 'cm');
$cmfrom = $cmtable->get_from_sql();
$acttable = new \core\dml\table($activity, 'a', 'a');
$actselect = $acttable->get_field_select();
$actfrom = $acttable->get_from_sql();
$sql = <<<EOF
SELECT cm.id as cmid
FROM {$cmfrom}
INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname
INNER JOIN {$actfrom} ON cm.instance = a.id AND cm.course = :courseid
WHERE cm.idnumber = :idnumber OR a.name = :name
EOF;
$result = $DB->get_record_sql($sql, [
'modname' => $activity,
'idnumber' => $identifier,
'name' => $identifier,
'courseid' => $courseid,
], MUST_EXIST);
return get_fast_modinfo($courseid)->get_cm($result->cmid);
}
/**
* Workaround while MDL-74621 is not integrated in all supported versions.
* This function will skip scenarios based on @lms_from and @lms_upto tags.
*/
public function skip_restricted_tags_scenarios() {
if (is_null($this->lmsversion)) {
global $CFG;
$version = trim($CFG->release);
$versionarr = explode(" ", $version);
if (!empty($versionarr)) {
$version = $versionarr[0];
}
// Replace everything but numbers and dots by dots.
$version = preg_replace('/[^\.\d]/', '.', $version);
// Combine multiple dots in one.
$version = preg_replace('/(\.{2,})/', '.', $version);
// Trim possible leading and trailing dots.
$this->lmsversion = trim($version, '.');
}
if ($this->has_version_restrictions()) {
// Skip this test.
throw new DriverException('Incompatible tags.');
}
}
/**
* Gets if version is incompatible with the @lms_from and @lms_upto tags.
*
* @return bool If scenario has any version incompatible tag.
*/
protected function has_version_restrictions() : bool {
$usedtags = behat_hooks::get_tags_for_scenario();
$detectedversioncount = substr_count($this->lmsversion, '.');
// Set up relevant tags for each version.
$usedtags = array_keys($usedtags);
foreach ($usedtags as $usedtag) {
if (!preg_match('~^lms_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) {
// No match, ignore.
continue;
}
$direction = $matches[1];
$version = $matches[2];
$versioncount = substr_count($version, '.');
// Compare versions on same length.
$detected = $this->lmsversion;
if ($versioncount < $detectedversioncount) {
$detected_parts = explode('.', $this->lmsversion);
array_splice($detected_parts, $versioncount - $detectedversioncount);
$detected = implode('.', $detected_parts);
}
$compare = version_compare($detected, $version);
// Installed version OLDER than the one being considered, so do not
// include any scenarios that only run from the considered version up.
if ($compare === -1 && $direction === 'from') {
return true;
}
// Installed version NEWER than the one being considered, so do not
// include any scenarios that only run up to that version.
if ($compare === 1 && $direction === 'upto') {
return true;
}
}
return false;
}
}

View File

@ -18,6 +18,8 @@ use Behat\Mink\Exception\DriverException;
use Facebook\WebDriver\Exception\InvalidArgumentException; use Facebook\WebDriver\Exception\InvalidArgumentException;
use Moodle\BehatExtension\Driver\WebDriver; use Moodle\BehatExtension\Driver\WebDriver;
require_once(__DIR__ . '/../behat_app.php');
/** /**
* Performance measures for one particular metric. * Performance measures for one particular metric.
*/ */
@ -334,7 +336,7 @@ class performance_measure implements behat_app_listener {
" ],", " ],",
" ],", " ],",
" ],", " ],",
");", "];",
"", "",
]) ])
); );

View File

@ -1,8 +1,8 @@
{ {
"app_id": "com.moodle.moodlemobile", "app_id": "com.moodle.moodlemobile",
"appname": "Moodle Mobile", "appname": "Moodle Mobile",
"versioncode": 40100, "versioncode": 41000,
"versionname": "4.0.1", "versionname": "4.1.0-dev",
"cache_update_frequency_usually": 420000, "cache_update_frequency_usually": 420000,
"cache_update_frequency_often": 1200000, "cache_update_frequency_often": 1200000,
"cache_update_frequency_sometimes": 3600000, "cache_update_frequency_sometimes": 3600000,
@ -76,9 +76,10 @@
"password": "moodle" "password": "moodle"
} }
}, },
"defaultZoomLevel": "none",
"zoomlevels": { "zoomlevels": {
"normal": 100, "none": 100,
"low": 110, "medium": 110,
"high": 120 "high": 120
}, },
"customurlscheme": "moodlemobile", "customurlscheme": "moodlemobile",

250
package-lock.json generated
View File

@ -1,48 +1,48 @@
{ {
"name": "moodlemobile", "name": "moodlemobile",
"version": "4.0.1", "version": "4.1.0-dev",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "moodlemobile", "name": "moodlemobile",
"version": "4.0.1", "version": "4.1.0-dev",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@angular/animations": "10.0.14", "@angular/animations": "~10.0.14",
"@angular/common": "10.0.14", "@angular/common": "~10.0.14",
"@angular/core": "10.0.14", "@angular/core": "~10.0.14",
"@angular/forms": "10.0.14", "@angular/forms": "~10.0.14",
"@angular/platform-browser": "10.0.14", "@angular/platform-browser": "~10.0.14",
"@angular/platform-browser-dynamic": "10.0.14", "@angular/platform-browser-dynamic": "~10.0.14",
"@angular/router": "10.0.14", "@angular/router": "~10.0.14",
"@ionic-native/badge": "5.33.0", "@ionic-native/badge": "^5.33.0",
"@ionic-native/camera": "5.33.0", "@ionic-native/camera": "^5.33.0",
"@ionic-native/chooser": "5.33.0", "@ionic-native/chooser": "^5.33.0",
"@ionic-native/clipboard": "5.33.0", "@ionic-native/clipboard": "^5.33.0",
"@ionic-native/core": "5.33.0", "@ionic-native/core": "^5.33.0",
"@ionic-native/device": "5.33.0", "@ionic-native/device": "^5.33.0",
"@ionic-native/diagnostic": "5.33.0", "@ionic-native/diagnostic": "^5.33.0",
"@ionic-native/file": "5.33.0", "@ionic-native/file": "^5.33.0",
"@ionic-native/file-opener": "5.33.0", "@ionic-native/file-opener": "^5.33.0",
"@ionic-native/file-transfer": "5.33.0", "@ionic-native/file-transfer": "^5.33.0",
"@ionic-native/geolocation": "5.33.0", "@ionic-native/geolocation": "^5.33.0",
"@ionic-native/http": "5.33.0", "@ionic-native/http": "^5.33.0",
"@ionic-native/in-app-browser": "5.33.0", "@ionic-native/in-app-browser": "^5.33.0",
"@ionic-native/ionic-webview": "5.33.0", "@ionic-native/ionic-webview": "^5.33.0",
"@ionic-native/keyboard": "5.33.0", "@ionic-native/keyboard": "^5.33.0",
"@ionic-native/local-notifications": "5.33.0", "@ionic-native/local-notifications": "^5.33.0",
"@ionic-native/media": "5.33.0", "@ionic-native/media": "^5.33.0",
"@ionic-native/media-capture": "5.33.0", "@ionic-native/media-capture": "^5.33.0",
"@ionic-native/network": "5.33.0", "@ionic-native/network": "^5.33.0",
"@ionic-native/push": "5.33.0", "@ionic-native/push": "^5.33.0",
"@ionic-native/qr-scanner": "5.33.0", "@ionic-native/qr-scanner": "^5.33.0",
"@ionic-native/splash-screen": "5.33.0", "@ionic-native/splash-screen": "^5.33.0",
"@ionic-native/sqlite": "5.33.0", "@ionic-native/sqlite": "^5.33.0",
"@ionic-native/status-bar": "5.33.0", "@ionic-native/status-bar": "^5.33.0",
"@ionic-native/web-intent": "5.33.0", "@ionic-native/web-intent": "^5.33.0",
"@ionic-native/zip": "5.33.0", "@ionic-native/zip": "^5.33.0",
"@ionic/angular": "5.9.2", "@ionic/angular": "^5.9.2",
"@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5", "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5",
"@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3",
"@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1", "@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1",
@ -50,106 +50,106 @@
"@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2", "@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2",
"@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1", "@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1",
"@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4", "@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4",
"@ngx-translate/core": "13.0.0", "@ngx-translate/core": "^13.0.0",
"@ngx-translate/http-loader": "6.0.0", "@ngx-translate/http-loader": "^6.0.0",
"@types/chart.js": "2.9.31", "@types/chart.js": "^2.9.31",
"@types/cordova": "0.0.34", "@types/cordova": "0.0.34",
"@types/dom-mediacapture-record": "1.0.7", "@types/dom-mediacapture-record": "^1.0.7",
"chart.js": "2.9.4", "chart.js": "^2.9.4",
"com-darryncampbell-cordova-plugin-intent": "2.2.0", "com-darryncampbell-cordova-plugin-intent": "^2.2.0",
"cordova": "11.0.0", "cordova": "^11.0.0",
"cordova-android": "10.1.1", "cordova-android": "^10.1.1",
"cordova-clipboard": "1.3.0", "cordova-clipboard": "^1.3.0",
"cordova-ios": "6.2.0", "cordova-ios": "^6.2.0",
"cordova-plugin-add-swift-support": "2.0.2", "cordova-plugin-add-swift-support": "^2.0.2",
"cordova-plugin-advanced-http": "3.2.2", "cordova-plugin-advanced-http": "^3.2.2",
"cordova-plugin-badge": "0.8.8", "cordova-plugin-badge": "^0.8.8",
"cordova-plugin-camera": "6.0.0", "cordova-plugin-camera": "^6.0.0",
"cordova-plugin-chooser": "1.3.2", "cordova-plugin-chooser": "^1.3.2",
"cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-customurlscheme": "^5.0.2",
"cordova-plugin-device": "2.0.3", "cordova-plugin-device": "^2.0.3",
"cordova-plugin-file": "6.0.2", "cordova-plugin-file": "^6.0.2",
"cordova-plugin-file-opener2": "3.0.5", "cordova-plugin-file-opener2": "^3.0.5",
"cordova-plugin-geolocation": "4.1.0", "cordova-plugin-geolocation": "^4.1.0",
"cordova-plugin-ionic-keyboard": "2.2.0", "cordova-plugin-ionic-keyboard": "^2.2.0",
"cordova-plugin-media": "5.0.4", "cordova-plugin-media": "^5.0.4",
"cordova-plugin-media-capture": "3.0.3", "cordova-plugin-media-capture": "^3.0.3",
"cordova-plugin-network-information": "3.0.0", "cordova-plugin-network-information": "^3.0.0",
"cordova-plugin-prevent-override": "1.0.1", "cordova-plugin-prevent-override": "^1.0.1",
"cordova-plugin-splashscreen": "6.0.0", "cordova-plugin-splashscreen": "^6.0.0",
"cordova-plugin-statusbar": "3.0.0", "cordova-plugin-statusbar": "^3.0.0",
"cordova-plugin-wkuserscript": "1.0.1", "cordova-plugin-wkuserscript": "^1.0.1",
"cordova-plugin-wkwebview-cookies": "1.0.1", "cordova-plugin-wkwebview-cookies": "^1.0.1",
"cordova-sqlite-storage": "6.0.0", "cordova-sqlite-storage": "^6.0.0",
"cordova.plugins.diagnostic": "6.1.1", "cordova.plugins.diagnostic": "^6.1.1",
"core-js": "3.9.1", "core-js": "^3.9.1",
"es6-promise-plugin": "4.2.2", "es6-promise-plugin": "^4.2.2",
"hammerjs": "2.0.8", "hammerjs": "^2.0.8",
"jszip": "3.7.1", "jszip": "^3.7.1",
"mathjax": "2.7.7", "mathjax": "2.7.7",
"moment": "2.29.2", "moment": "^2.29.2",
"nl.kingsquare.cordova.background-audio": "1.0.1", "nl.kingsquare.cordova.background-audio": "^1.0.1",
"rxjs": "6.5.5", "rxjs": "~6.5.5",
"ts-md5": "1.2.7", "ts-md5": "^1.2.7",
"tslib": "2.3.1", "tslib": "^2.3.1",
"zone.js": "0.10.3" "zone.js": "~0.10.3"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "10.0.1", "@angular-builders/custom-webpack": "^10.0.1",
"@angular-devkit/architect": "0.1202.7", "@angular-devkit/architect": "^0.1202.7",
"@angular-devkit/build-angular": "0.1000.8", "@angular-devkit/build-angular": "~0.1000.8",
"@angular-eslint/builder": "4.2.0", "@angular-eslint/builder": "^4.2.0",
"@angular-eslint/eslint-plugin": "4.2.0", "@angular-eslint/eslint-plugin": "^4.2.0",
"@angular-eslint/eslint-plugin-template": "4.2.0", "@angular-eslint/eslint-plugin-template": "^4.2.0",
"@angular-eslint/schematics": "4.2.0", "@angular-eslint/schematics": "^4.2.0",
"@angular-eslint/template-parser": "4.2.0", "@angular-eslint/template-parser": "^4.2.0",
"@angular/cli": "10.0.8", "@angular/cli": "~10.0.8",
"@angular/compiler": "10.0.14", "@angular/compiler": "~10.0.14",
"@angular/compiler-cli": "10.0.14", "@angular/compiler-cli": "~10.0.14",
"@angular/language-service": "10.0.14", "@angular/language-service": "~10.0.14",
"@ionic/angular-toolkit": "2.3.3", "@ionic/angular-toolkit": "^2.3.3",
"@ionic/cli": "6.19.0", "@ionic/cli": "^6.19.0",
"@types/faker": "5.1.3", "@types/faker": "^5.1.3",
"@types/node": "12.12.64", "@types/node": "^12.12.64",
"@types/resize-observer-browser": "0.1.5", "@types/resize-observer-browser": "^0.1.5",
"@types/webpack-env": "1.16.0", "@types/webpack-env": "^1.16.0",
"@typescript-eslint/eslint-plugin": "4.22.0", "@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "4.22.0", "@typescript-eslint/parser": "^4.22.0",
"check-es-compat": "1.1.1", "check-es-compat": "^1.1.1",
"cordova-plugin-androidx-adapter": "1.1.3", "cordova-plugin-androidx-adapter": "^1.1.3",
"cordova-plugin-screen-orientation": "^3.0.2", "cordova-plugin-screen-orientation": "^3.0.2",
"cross-env": "7.0.3", "cross-env": "^7.0.3",
"eslint": "7.25.0", "eslint": "^7.25.0",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-header": "3.1.1", "eslint-plugin-header": "^3.1.1",
"eslint-plugin-import": "2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "24.3.6", "eslint-plugin-jest": "^24.3.6",
"eslint-plugin-jsdoc": "32.3.3", "eslint-plugin-jsdoc": "^32.3.3",
"eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-promise": "5.1.0", "eslint-plugin-promise": "^5.1.0",
"faker": "5.1.0", "faker": "^5.1.0",
"fs-extra": "9.1.0", "fs-extra": "^9.1.0",
"gulp": "4.0.2", "gulp": "4.0.2",
"gulp-clip-empty-files": "0.1.2", "gulp-clip-empty-files": "^0.1.2",
"gulp-concat": "2.6.1", "gulp-concat": "^2.6.1",
"gulp-flatten": "0.4.0", "gulp-flatten": "^0.4.0",
"gulp-htmlmin": "5.0.1", "gulp-htmlmin": "^5.0.1",
"gulp-rename": "2.0.0", "gulp-rename": "^2.0.0",
"gulp-slash": "1.1.3", "gulp-slash": "^1.1.3",
"jest": "26.5.2", "jest": "^26.5.2",
"jest-preset-angular": "8.3.1", "jest-preset-angular": "^8.3.1",
"jsonc-parser": "2.3.1", "jsonc-parser": "^2.3.1",
"native-run": "1.4.0", "native-run": "^1.4.0",
"terser-webpack-plugin": "4.2.3", "terser-webpack-plugin": "^4.2.3",
"ts-jest": "26.4.1", "ts-jest": "^26.4.1",
"ts-node": "8.3.0", "ts-node": "~8.3.0",
"typescript": "3.9.9" "typescript": "^3.9.9"
}, },
"engines": { "engines": {
"node": ">=14.15.0 <15" "node": ">=14.15.0 <15"
}, },
"optionalDependencies": { "optionalDependencies": {
"keytar": "7.2.0" "keytar": "^7.2.0"
} }
}, },
"node_modules/@angular-builders/custom-webpack": { "node_modules/@angular-builders/custom-webpack": {

View File

@ -1,6 +1,6 @@
{ {
"name": "moodlemobile", "name": "moodlemobile",
"version": "4.0.1", "version": "4.1.0-dev",
"description": "The official app for Moodle.", "description": "The official app for Moodle.",
"author": { "author": {
"name": "Moodle Pty Ltd.", "name": "Moodle Pty Ltd.",
@ -38,40 +38,40 @@
"ionic:build:before": "gulp" "ionic:build:before": "gulp"
}, },
"dependencies": { "dependencies": {
"@angular/animations": "10.0.14", "@angular/animations": "~10.0.14",
"@angular/common": "10.0.14", "@angular/common": "~10.0.14",
"@angular/core": "10.0.14", "@angular/core": "~10.0.14",
"@angular/forms": "10.0.14", "@angular/forms": "~10.0.14",
"@angular/platform-browser": "10.0.14", "@angular/platform-browser": "~10.0.14",
"@angular/platform-browser-dynamic": "10.0.14", "@angular/platform-browser-dynamic": "~10.0.14",
"@angular/router": "10.0.14", "@angular/router": "~10.0.14",
"@ionic-native/badge": "5.33.0", "@ionic-native/badge": "^5.33.0",
"@ionic-native/camera": "5.33.0", "@ionic-native/camera": "^5.33.0",
"@ionic-native/chooser": "5.33.0", "@ionic-native/chooser": "^5.33.0",
"@ionic-native/clipboard": "5.33.0", "@ionic-native/clipboard": "^5.33.0",
"@ionic-native/core": "5.33.0", "@ionic-native/core": "^5.33.0",
"@ionic-native/device": "5.33.0", "@ionic-native/device": "^5.33.0",
"@ionic-native/diagnostic": "5.33.0", "@ionic-native/diagnostic": "^5.33.0",
"@ionic-native/file": "5.33.0", "@ionic-native/file": "^5.33.0",
"@ionic-native/file-opener": "5.33.0", "@ionic-native/file-opener": "^5.33.0",
"@ionic-native/file-transfer": "5.33.0", "@ionic-native/file-transfer": "^5.33.0",
"@ionic-native/geolocation": "5.33.0", "@ionic-native/geolocation": "^5.33.0",
"@ionic-native/http": "5.33.0", "@ionic-native/http": "^5.33.0",
"@ionic-native/in-app-browser": "5.33.0", "@ionic-native/in-app-browser": "^5.33.0",
"@ionic-native/ionic-webview": "5.33.0", "@ionic-native/ionic-webview": "^5.33.0",
"@ionic-native/keyboard": "5.33.0", "@ionic-native/keyboard": "^5.33.0",
"@ionic-native/local-notifications": "5.33.0", "@ionic-native/local-notifications": "^5.33.0",
"@ionic-native/media": "5.33.0", "@ionic-native/media": "^5.33.0",
"@ionic-native/media-capture": "5.33.0", "@ionic-native/media-capture": "^5.33.0",
"@ionic-native/network": "5.33.0", "@ionic-native/network": "^5.33.0",
"@ionic-native/push": "5.33.0", "@ionic-native/push": "^5.33.0",
"@ionic-native/qr-scanner": "5.33.0", "@ionic-native/qr-scanner": "^5.33.0",
"@ionic-native/splash-screen": "5.33.0", "@ionic-native/splash-screen": "^5.33.0",
"@ionic-native/sqlite": "5.33.0", "@ionic-native/sqlite": "^5.33.0",
"@ionic-native/status-bar": "5.33.0", "@ionic-native/status-bar": "^5.33.0",
"@ionic-native/web-intent": "5.33.0", "@ionic-native/web-intent": "^5.33.0",
"@ionic-native/zip": "5.33.0", "@ionic-native/zip": "^5.33.0",
"@ionic/angular": "5.9.2", "@ionic/angular": "^5.9.2",
"@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5", "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5",
"@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3",
"@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1", "@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1",
@ -79,100 +79,100 @@
"@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2", "@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2",
"@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1", "@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1",
"@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4", "@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4",
"@ngx-translate/core": "13.0.0", "@ngx-translate/core": "^13.0.0",
"@ngx-translate/http-loader": "6.0.0", "@ngx-translate/http-loader": "^6.0.0",
"@types/chart.js": "2.9.31", "@types/chart.js": "^2.9.31",
"@types/cordova": "0.0.34", "@types/cordova": "0.0.34",
"@types/dom-mediacapture-record": "1.0.7", "@types/dom-mediacapture-record": "^1.0.7",
"chart.js": "2.9.4", "chart.js": "^2.9.4",
"com-darryncampbell-cordova-plugin-intent": "2.2.0", "com-darryncampbell-cordova-plugin-intent": "^2.2.0",
"cordova": "11.0.0", "cordova": "^11.0.0",
"cordova-android": "10.1.1", "cordova-android": "^10.1.1",
"cordova-clipboard": "1.3.0", "cordova-clipboard": "^1.3.0",
"cordova-ios": "6.2.0", "cordova-ios": "^6.2.0",
"cordova-plugin-add-swift-support": "2.0.2", "cordova-plugin-add-swift-support": "^2.0.2",
"cordova-plugin-advanced-http": "3.2.2", "cordova-plugin-advanced-http": "^3.2.2",
"cordova-plugin-badge": "0.8.8", "cordova-plugin-badge": "^0.8.8",
"cordova-plugin-camera": "6.0.0", "cordova-plugin-camera": "^6.0.0",
"cordova-plugin-chooser": "1.3.2", "cordova-plugin-chooser": "^1.3.2",
"cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-customurlscheme": "^5.0.2",
"cordova-plugin-device": "2.0.3", "cordova-plugin-device": "^2.0.3",
"cordova-plugin-file": "6.0.2", "cordova-plugin-file": "^6.0.2",
"cordova-plugin-file-opener2": "3.0.5", "cordova-plugin-file-opener2": "^3.0.5",
"cordova-plugin-geolocation": "4.1.0", "cordova-plugin-geolocation": "^4.1.0",
"cordova-plugin-ionic-keyboard": "2.2.0", "cordova-plugin-ionic-keyboard": "^2.2.0",
"cordova-plugin-media": "5.0.4", "cordova-plugin-media": "^5.0.4",
"cordova-plugin-media-capture": "3.0.3", "cordova-plugin-media-capture": "^3.0.3",
"cordova-plugin-network-information": "3.0.0", "cordova-plugin-network-information": "^3.0.0",
"cordova-plugin-prevent-override": "1.0.1", "cordova-plugin-prevent-override": "^1.0.1",
"cordova-plugin-splashscreen": "6.0.0", "cordova-plugin-splashscreen": "^6.0.0",
"cordova-plugin-statusbar": "3.0.0", "cordova-plugin-statusbar": "^3.0.0",
"cordova-plugin-wkuserscript": "1.0.1", "cordova-plugin-wkuserscript": "^1.0.1",
"cordova-plugin-wkwebview-cookies": "1.0.1", "cordova-plugin-wkwebview-cookies": "^1.0.1",
"cordova-sqlite-storage": "6.0.0", "cordova-sqlite-storage": "^6.0.0",
"cordova.plugins.diagnostic": "6.1.1", "cordova.plugins.diagnostic": "^6.1.1",
"core-js": "3.9.1", "core-js": "^3.9.1",
"es6-promise-plugin": "4.2.2", "es6-promise-plugin": "^4.2.2",
"hammerjs": "2.0.8", "hammerjs": "^2.0.8",
"jszip": "3.7.1", "jszip": "^3.7.1",
"mathjax": "2.7.7", "mathjax": "2.7.7",
"moment": "2.29.2", "moment": "^2.29.2",
"nl.kingsquare.cordova.background-audio": "1.0.1", "nl.kingsquare.cordova.background-audio": "^1.0.1",
"rxjs": "6.5.5", "rxjs": "~6.5.5",
"ts-md5": "1.2.7", "ts-md5": "^1.2.7",
"tslib": "2.3.1", "tslib": "^2.3.1",
"zone.js": "0.10.3" "zone.js": "~0.10.3"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "10.0.1", "@angular-builders/custom-webpack": "^10.0.1",
"@angular-devkit/architect": "0.1202.7", "@angular-devkit/architect": "^0.1202.7",
"@angular-devkit/build-angular": "0.1000.8", "@angular-devkit/build-angular": "~0.1000.8",
"@angular-eslint/builder": "4.2.0", "@angular-eslint/builder": "^4.2.0",
"@angular-eslint/eslint-plugin": "4.2.0", "@angular-eslint/eslint-plugin": "^4.2.0",
"@angular-eslint/eslint-plugin-template": "4.2.0", "@angular-eslint/eslint-plugin-template": "^4.2.0",
"@angular-eslint/schematics": "4.2.0", "@angular-eslint/schematics": "^4.2.0",
"@angular-eslint/template-parser": "4.2.0", "@angular-eslint/template-parser": "^4.2.0",
"@angular/cli": "10.0.8", "@angular/cli": "~10.0.8",
"@angular/compiler": "10.0.14", "@angular/compiler": "~10.0.14",
"@angular/compiler-cli": "10.0.14", "@angular/compiler-cli": "~10.0.14",
"@angular/language-service": "10.0.14", "@angular/language-service": "~10.0.14",
"@ionic/angular-toolkit": "2.3.3", "@ionic/angular-toolkit": "^2.3.3",
"@ionic/cli": "6.19.0", "@ionic/cli": "^6.19.0",
"@types/faker": "5.1.3", "@types/faker": "^5.1.3",
"@types/node": "12.12.64", "@types/node": "^12.12.64",
"@types/resize-observer-browser": "0.1.5", "@types/resize-observer-browser": "^0.1.5",
"@types/webpack-env": "1.16.0", "@types/webpack-env": "^1.16.0",
"@typescript-eslint/eslint-plugin": "4.22.0", "@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "4.22.0", "@typescript-eslint/parser": "^4.22.0",
"check-es-compat": "1.1.1", "check-es-compat": "^1.1.1",
"cordova-plugin-androidx-adapter": "1.1.3", "cordova-plugin-androidx-adapter": "^1.1.3",
"cordova-plugin-screen-orientation": "^3.0.2", "cordova-plugin-screen-orientation": "^3.0.2",
"cross-env": "7.0.3", "cross-env": "^7.0.3",
"eslint": "7.25.0", "eslint": "^7.25.0",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-header": "3.1.1", "eslint-plugin-header": "^3.1.1",
"eslint-plugin-import": "2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "24.3.6", "eslint-plugin-jest": "^24.3.6",
"eslint-plugin-jsdoc": "32.3.3", "eslint-plugin-jsdoc": "^32.3.3",
"eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-promise": "5.1.0", "eslint-plugin-promise": "^5.1.0",
"faker": "5.1.0", "faker": "^5.1.0",
"fs-extra": "9.1.0", "fs-extra": "^9.1.0",
"gulp": "4.0.2", "gulp": "4.0.2",
"gulp-clip-empty-files": "0.1.2", "gulp-clip-empty-files": "^0.1.2",
"gulp-concat": "2.6.1", "gulp-concat": "^2.6.1",
"gulp-flatten": "0.4.0", "gulp-flatten": "^0.4.0",
"gulp-htmlmin": "5.0.1", "gulp-htmlmin": "^5.0.1",
"gulp-rename": "2.0.0", "gulp-rename": "^2.0.0",
"gulp-slash": "1.1.3", "gulp-slash": "^1.1.3",
"jest": "26.5.2", "jest": "^26.5.2",
"jest-preset-angular": "8.3.1", "jest-preset-angular": "^8.3.1",
"jsonc-parser": "2.3.1", "jsonc-parser": "^2.3.1",
"native-run": "1.4.0", "native-run": "^1.4.0",
"terser-webpack-plugin": "4.2.3", "terser-webpack-plugin": "^4.2.3",
"ts-jest": "26.4.1", "ts-jest": "^26.4.1",
"ts-node": "8.3.0", "ts-node": "~8.3.0",
"typescript": "3.9.9" "typescript": "^3.9.9"
}, },
"engines": { "engines": {
"node": ">=14.15.0 <15" "node": ">=14.15.0 <15"
@ -243,6 +243,6 @@
} }
}, },
"optionalDependencies": { "optionalDependencies": {
"keytar": "7.2.0" "keytar": "^7.2.0"
} }
} }

View File

@ -14,37 +14,82 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
const { existsSync, readFileSync, writeFileSync } = require('fs'); const { existsSync, readFileSync, writeFileSync, statSync, renameSync, rmSync } = require('fs');
const { readdir } = require('fs').promises; const { readdir } = require('fs').promises;
const { mkdirSync, copySync } = require('fs-extra'); const { mkdirSync, copySync } = require('fs-extra');
const { resolve } = require('path'); const { resolve, extname, dirname, basename, relative } = require('path');
async function main() { async function main() {
const pluginPath = process.argv[2] || guessPluginPath() || fail('Folder argument missing!'); const pluginPath = process.argv[2] || guessPluginPath() || fail('Folder argument missing!');
if (!existsSync(pluginPath)) { if (!existsSync(pluginPath)) {
mkdirSync(pluginPath); mkdirSync(pluginPath);
} else {
// Empty directory, except the excluding list.
const excludeFromErase = [
'.git',
'.gitignore',
'README.md',
];
const files = await readdir(pluginPath, { withFileTypes: true });
for (const file of files) {
if (excludeFromErase.indexOf(file.name) >= 0) {
continue;
} }
const path = resolve(pluginPath, file.name);
rmSync(`${path}`, {recursive: true});
}
}
// Copy plugin template. // Copy plugin template.
const { version: appVersion } = require(projectPath('package.json')); const { version: appVersion } = require(projectPath('package.json'));
const templatePath = projectPath('scripts/templates/behat-plugin'); const templatePath = projectPath('local-moodleappbehat');
copySync(templatePath, pluginPath);
// Update version.php
const pluginFilePath = pluginPath + '/version.php';
const fileContents = readFileSync(pluginFilePath).toString();
const replacements = { const replacements = {
appVersion, appVersion,
pluginVersion: getMoodlePluginVersion(), pluginVersion: getMoodlePluginVersion(),
}; };
copySync(templatePath, pluginPath);
for await (const templateFilePath of getDirectoryFiles(templatePath)) {
const pluginFilePath = pluginPath + templateFilePath.substr(templatePath.length);
const fileContents = readFileSync(pluginFilePath).toString();
writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements));
// Copy feature files.
const behatTempFeaturesPath = `${pluginPath}/behat-tmp`;
copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory });
const behatFeaturesPath = `${pluginPath}/tests/behat`;
if (!existsSync(behatFeaturesPath)) {
mkdirSync(behatFeaturesPath, {recursive: true});
} }
// Copy features. for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) {
copySync(projectPath('tests/behat'), `${pluginPath}/tests/behat`); const featurePath = dirname(featureFile);
if (!featurePath.endsWith('/tests/behat')) {
continue;
}
const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length));
const searchRegExp = new RegExp('/', 'g');
const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core';
const featureFilename = prefix + '-' + basename(featureFile);
renameSync(featureFile, behatFeaturesPath + '/' + featureFilename);
}
rmSync(behatTempFeaturesPath, {recursive: true});
}
function isFeatureFileOrDirectory(src) {
const stats = statSync(src);
return stats.isDirectory() || extname(src) === '.feature';
} }
function fail(message) { function fail(message) {

View File

@ -0,0 +1,396 @@
@core @core_message @app @javascript
Feature: Test basic usage of messages in app
In order to participate with messages while using the mobile app
As a student
I need basic message functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student1 | student1 | student1@example.com |
| student2 | Student2 | student2 | student2@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
Scenario: View recent conversations and contacts
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Contacts" in the app
Then I should find "No contacts" in the app
When I press "Search people and messages" in the app
And I set the field "Search" to "student" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I set the field "New message" to "heeey student" in the app
And I press "Send" in the app
And I press "Display options" in the app
And I press "Add to contacts" in the app
And I press "Add" near "Are you sure you want to add Student1 student1 to your contacts?" in the app
Then I should find "Contact request sent" in the app
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Requests" in the app
And I press "Teacher teacher" in the app
And I press "Accept and add to contacts" in the app
Then I should not find "Teacher teacher would like to contact you" in the app
When I press the back button in the app
And I press "Contacts" near "No contact requests" in the app
Then the header should be "Contacts" in the app
And I should find "Teacher teacher" in the app
When I press the back button in the app
And I press "Teacher teacher" in the app
Then the header should be "Teacher teacher" in the app
And I should find "heeey student" in the app
Scenario: Search users
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student2" in the app
And I press "Search" "button" in the app
Then I should find "Student2 student2" in the app
When I set the field "Search" to "Teacher" in the app
And I press "Search" "button" in the app
Then I should find "Teacher teacher" in the app
Scenario: Send/receive messages in existing conversations
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I set the field "New message" to "heeey student" in the app
And I press "Send" in the app
Then I should find "heeey student" in the app
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "teacher" in the app
And I press "Search" "button" in the app
And I press "Teacher teacher" in the app
Then I should find "heeey student" in the app
When I set the field "New message" to "hi" in the app
And I press "Send" in the app
Then I should find "hi" in the app
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
Then I should find "heeey student" in the app
And I should find "hi" in the app
When I set the field "New message" to "byee" in the app
And I press "Send" in the app
Then I should find "heeey student" in the app
And I should find "hi" in the app
And I should find "byee" in the app
# TODO Fix this test in all Moodle versions
Scenario: User profile: send message, add/remove contact
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I set the field "New message" to "heeey student" in the app
And I press "Send" in the app
Then I should find "heeey student" in the app
When I press "Display options" in the app
And I press "Add to contacts" in the app
And I press "Add" in the app
Then I should find "Contact request sent" in the app
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Requests" in the app
And I press "Teacher teacher" in the app
Then I should find "Teacher teacher would like to contact you" in the app
When I press "Accept and add to contacts" in the app
Then I should not find "Teacher teacher would like to contact you" in the app
When I press "Display options" in the app
And I press "User info" in the app
And I press "Message" in the app
And I set the field "New message" to "hi" in the app
And I press "Send" "button" in the app
Then I should find "heeey student" in the app
And I should find "hi" in the app
When I press "Display options" in the app
And I press "Remove from contacts" in the app
And I press "Remove" in the app
And I wait loading to finish in the app
And I press the back button in the app
And I press the back button in the app
And I press "Display options" in the app
Then I should find "Add to contacts" in the app
When I press "Delete conversation" in the app
And I press "Delete" near "Are you sure you would like to delete this entire conversation?" in the app
Then I should not find "heeey student" in the app
And I should not find "hi" in the app
Scenario: Send message offline
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I switch offline mode to "true"
And I set the field "New message" to "heeey student" in the app
And I press "Send" in the app
Then I should find "heeey student" in the app
When I set the field "New message" to "byee" in the app
And I press "Send" in the app
Then I should find "byee" in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "Student1 student1" in the app
Then I should find "heeey student" in the app
And I should find "byee" in the app
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Teacher teacher" in the app
Then I should find "heeey student" in the app
And I should find "byee" in the app
Scenario: Auto-sync messages
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I switch offline mode to "true"
And I set the field "New message" to "heeey student" in the app
And I press "Send" in the app
And I set the field "New message" to "byee" in the app
And I press "Send" in the app
Then I should find "byee" in the app
When I switch offline mode to "false"
And I run cron tasks in the app
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Teacher teacher" in the app
Then I should find "heeey student" in the app
And I should find "byee" in the app
Scenario: Search for messages
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I set the field "New message" to "test message" in the app
And I press "Send" in the app
Then I should find "test message" in the app
When I set the field "New message" to "search this message" in the app
And I press "Send" in the app
Then I should find "search this message" in the app
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "search this message" in the app
And I press "Search" "button" in the app
Then I should find "Messages" in the app
And I should find "search this message" in the app
When I press "search this message" near "Teacher teacher" in the app
Then I should find "test message" in the app
And I should find "search this message" in the app
Scenario: Star/Unstar
Given I entered the app as "teacher1"
When I press "Messages" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I set the field "New message" to "star message" in the app
And I press "Send" in the app
Then I should find "star message" in the app
Given I entered the app as "student2"
When I press "Messages" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I set the field "New message" to "test message student2" in the app
And I press "Send" in the app
Then I should find "test message student2" in the app
Given I entered the app as "student1"
When I press "Messages" in the app
Then I should find "Private (2)" in the app
And I should find "Starred (1)" in the app
When I press "star message" in the app
And I press "Display options" in the app
And I press "Star conversation" in the app
And I press the back button in the app
Then I should find "Private (1)" in the app
And I should find "Starred (2)" in the app
When I press "Starred (2)" in the app
Then I should find "Teacher teacher" in the app
And I should find "Student1 student1" in the app
Scenario: User blocking feature
Given I entered the course "Course 1" as "student2" in the app
When I press "Participants" in the app
And I press "Student1 student1" in the app
And I press "Message" in the app
And I press "Display options" in the app
And I press "Block user" in the app
And I press "Block user" near "Are you sure you want to block Student1 student1?" in the app
Then I should find "You have blocked this user" in the app
Given I entered the course "Course 1" as "student1" in the app
When I press "Participants" in the app
And I press "Student2 student2" in the app
And I press "Message" in the app
Then I should find "You are unable to message this user" in the app
Given I entered the course "Course 1" as "student2" in the app
When I press "Participants" in the app
And I press "Student1 student1" in the app
And I press "Message" in the app
And I press "Display options" in the app
Then I should find "Unblock user" in the app
But I should not find "Block user" in the app
When I press "Unblock user" in the app
And I press "Unblock user" near "Are you sure you want to unblock Student1 student1?" in the app
Then I should not find "You have blocked this user" in the app
Given I entered the course "Course 1" as "student1" in the app
When I press "Participants" in the app
And I press "Student2 student2" in the app
And I press "Message" in the app
And I set the field "New message" to "test message" in the app
And I press "Send" in the app
Then I should find "test message" in the app
But I should not find "You are unable to message this user" in the app
Scenario: Mute Unmute conversations
Given I entered the course "Course 1" as "student1" in the app
When I press "Participants" in the app
And I press "Student2 student2" in the app
And I press "Message" in the app
And I set the field "New message" to "test message" in the app
And I press "Send" in the app
Then I should find "test message" in the app
When I press "Display options" in the app
And I press "Mute" in the app
Then I should find "Muted conversation" in the app
When I press "Display options" in the app
And I press "Unmute" in the app
Then I should not find "Muted conversation" in the app
When I press "Display options" in the app
When I press "Mute" in the app
Then I should find "Muted conversation" in the app
When I press "Messages" in the app
And I press "Private (1)" in the app
And I press "Student2 student2" in the app
Then I should find "test message" in the app
And I should find "Muted conversation" in the app
Scenario: Self conversations
Given I entered the app as "student1"
When I press "Messages" in the app
Then I should find "Starred (1)" in the app
When I press "Student1 student1" in the app
And I set the field "New message" to "self conversation online" in the app
And I press "Send" in the app
Then I should find "self conversation online" in the app
When I switch offline mode to "true"
And I set the field "New message" to "self conversation offline" in the app
And I press "Send" in the app
Then I should find "self conversation offline" in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "Student1 student1" in the app
And I press "Display options" in the app
Then I should find "Show delete messages" in the app
And I should find "Delete conversation" in the app
When I press "Unstar conversation" in the app
And I press "Display options" in the app
Then I should find "Star conversation" in the app
And I should find "Delete conversation" in the app
When I press "Show delete messages" in the app
And I close the popup in the app
Then I should find "self conversation online" in the app
And I should find "self conversation offline" in the app
When I press "Delete message" near "self conversation offline" in the app
And I press "OK" in the app
Then I should find "self conversation online" in the app
But I should not find "self conversation offline" in the app
When I press "Display options" in the app
And I press "Delete conversation" in the app
And I press "Delete" near "Are you sure you would like to delete this entire personal conversation?" in the app
Then I should not find "self conversation online" in the app
And I should not find "self conversation offline" in the app
When I press the back button in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "Student1 student1" in the app
And I press "Search" "button" in the app
And I press "Student1 student1" in the app
And I set the field "New message" to "auto search test" in the app
And I press "Send" in the app
Then I should find "auto search test" in the app
When I press the back button in the app
And I press the back button in the app
And I press "Private" in the app
And I press "Student1 student1" in the app
Then I should find "auto search test" in the app

View File

@ -0,0 +1,42 @@
@core @core_message @app @javascript
Feature: Test messages navigation in the app
Background:
Given the following "users" exist:
| username | firstname |
| teacher | Teacher |
| student | Student |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C1 | editingteacher |
| student | C1 | student |
Scenario: Avoid recursive links to profile
Given I entered the app as "teacher"
When I press "Messages" in the app
And I press "Contacts" in the app
And I press "Search people and messages" in the app
And I set the field "Search" to "student" in the app
And I press "Search" "button" in the app
And I press "Student" in the app
And I set the field "New message" to "Hi there" in the app
And I press "Send" in the app
Then I should find "Hi there" in the app
When I press "Display options" in the app
And I press "User info" in the app
Then I should find "Details" in the app
When I press "Message" in the app
Then I should find "Hi there" in the app
When I press "Display options" in the app
Then I should not find "User info" in the app
When I close the popup in the app
And I press the back button in the app
And I press the back button in the app
Then I should find "Hi there" in the app

View File

@ -0,0 +1,17 @@
@core @core_message @app @javascript
Feature: Test messages settings
Background:
Given the following "users" exist:
| username |
| student1 |
Scenario: Modify settings
Given I entered the app as "student1"
When I press "Messages" in the app
And I press "Message preferences" in the app
And I select "My contacts only" in the app
Then "My contacts only" should be selected in the app
And I select "My contacts and anyone in my courses" in the app
Then "My contacts and anyone in my courses" should be selected in the app

View File

@ -0,0 +1,58 @@
@mod @mod_assign @app @javascript @lms_upto3.10
Feature: Test basic usage of assignment activity in app
In order to participate in the assignment while using the mobile app
I need basic assignment functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled | duedate | attemptreopenmethod |
| assign | C1 | assign1 | assignment1 | Test assignment description1 | 1 | 1029844800 | manual |
Scenario: View assign description, due date & View list of student submissions (as teacher) & View own submission or student submission
# Create, edit and submit as a student
Given I entered the assign activity "assignment1" on course "Course 1" as "student1" in the app
Then the header should be "assignment1" in the app
And I should find "Test assignment description1" in the app
And I should find "Due date" in the app
And I should find "Tuesday, 20 August 2002, 12:00 PM" in the app
When I press "Add submission" in the app
And I set the field "Online text submissions" to "Submission test" in the app
And I press "Save" in the app
Then I should find "Draft (not submitted)" in the app
And I should find "Not graded" in the app
When I press "Edit submission" in the app
And I set the field "Online text submissions" to "Submission test edited" in the app
And I press "Save" in the app
And I press "OK" in the app
Then I should find "Submission test edited" in the app
When I press "Submit assignment" in the app
And I press "OK" in the app
Then I should find "Submitted for grading" in the app
And I should find "Not graded" in the app
And I should find "Submission test edited" in the app
# View as a teacher
Given I entered the assign activity "assignment1" on course "Course 1" as "teacher1" in the app
Then the header should be "assignment1" in the app
When I press "Submitted" in the app
Then I should find "Student student" in the app
And I should find "Not graded" in the app
When I press "Student student" near "assignment1" in the app
Then I should find "Online text submissions" in the app
And I should find "Submission test edited" in the app

View File

@ -0,0 +1,147 @@
@mod @mod_assign @app @javascript
Feature: Test basic usage of assignment activity in app
In order to participate in the assignment while using the mobile app
I need basic assignment functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled | duedate | attemptreopenmethod |
| assign | C1 | assign1 | assignment1 | Test assignment description1 | 1 | 1029844800 | manual |
@lms_from3.11
Scenario: View assign description, due date & View list of student submissions (as teacher) & View own submission or student submission
# Create, edit and submit as a student
Given I entered the assign activity "assignment1" on course "Course 1" as "student1" in the app
Then the header should be "assignment1" in the app
And I should find "Test assignment description1" in the app
And I should find "Due:" in the app
And I should find "20 August 2002, 12:00 PM" in the app
When I press "Add submission" in the app
And I set the field "Online text submissions" to "Submission test" in the app
And I press "Save" in the app
Then I should find "Draft (not submitted)" in the app
And I should find "Not graded" in the app
When I press "Edit submission" in the app
And I set the field "Online text submissions" to "Submission test edited" in the app
And I press "Save" in the app
And I press "OK" in the app
Then I should find "Submission test edited" in the app
When I press "Submit assignment" in the app
And I press "OK" in the app
Then I should find "Submitted for grading" in the app
And I should find "Not graded" in the app
And I should find "Submission test edited" in the app
# View as a teacher
Given I entered the assign activity "assignment1" on course "Course 1" as "teacher1" in the app
Then the header should be "assignment1" in the app
When I press "Submitted" in the app
Then I should find "Student student" in the app
And I should find "Not graded" in the app
When I press "Student student" near "assignment1" in the app
Then I should find "Online text submissions" in the app
And I should find "Submission test edited" in the app
Scenario: Edit/Add submission (online text) & Add new attempt from previous submission & Submit for grading
# Submit first attempt as a student
Given I entered the assign activity "assignment1" on course "Course 1" as "student1" in the app
When I press "Add submission" in the app
Then I set the field "Online text submissions" to "Submission test 1st attempt" in the app
And I press "Save" in the app
And I press "Submit assignment" in the app
And I press "OK" in the app
# Allow more attempts as a teacher
Given I entered the assign activity "assignment1" on course "Course 1" as "teacher1" in the app
When I press "Participants" in the app
And I press "Student student" near "assignment1" in the app
And I press "Grade" in the app
And I press "Allow another attempt" in the app
And I press "Done" in the app
Then I should find "Reopened" in the app
And I should find "Not graded" in the app
# Submit second attempt as a student
Given I entered the assign activity "assignment1" on course "Course 1" as "student1" in the app
Then I should find "Reopened" in the app
And I should find "2 out of Unlimited" in the app
And I should find "Add a new attempt based on previous submission" in the app
And I should find "Add a new attempt" in the app
When I press "Add a new attempt based on previous submission" in the app
And I press "OK" in the app
Then I should find "Submission test 1st attempt" in the app
When I set the field "Online text submissions" to "Submission test 2nd attempt" in the app
And I press "Save" in the app
And I press "OK" in the app
And I press "Submit assignment" in the app
And I press "OK" in the app
# View second attempt as a teacher
Given I entered the assign activity "assignment1" on course "Course 1" as "teacher1" in the app
When I press "Participants" in the app
And I press "Student student" near "assignment1" in the app
Then I should find "Online text submissions" in the app
And I should find "Submission test 2nd attempt" in the app
Scenario: Add submission offline (online text) & Submit for grading offline & Sync submissions
Given I entered the assign activity "assignment1" on course "Course 1" as "student1" in the app
When I press "Add submission" in the app
And I switch offline mode to "true"
And I set the field "Online text submissions" to "Submission test" in the app
And I press "Save" in the app
And I press "Submit assignment" in the app
And I press "OK" in the app
Then I should find "This Assignment has offline data to be synchronised." in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "assignment1" in the app
And I press "Information" in the app
And I press "Refresh" in the app
Then I should find "Submitted for grading" in the app
But I should not find "This Assignment has offline data to be synchronised." in the app
Scenario: Edit an offline submission before synchronising it
Given I entered the assign activity "assignment1" on course "Course 1" as "student1" in the app
When I press "Add submission" in the app
And I switch offline mode to "true"
And I set the field "Online text submissions" to "Submission test original offline" in the app
And I press "Save" in the app
Then I should find "This Assignment has offline data to be synchronised." in the app
And I should find "Submission test original offline" in the app
When I press "Edit submission" in the app
And I set the field "Online text submissions" to "Submission test edited offline" in the app
And I press "Save" in the app
Then I should find "This Assignment has offline data to be synchronised." in the app
And I should find "Submission test edited offline" in the app
But I should not find "Submission test original offline" in the app
When I press "Submit assignment" in the app
And I press "OK" in the app
Then I should find "This Assignment has offline data to be synchronised." in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "assignment1" in the app
Then I should find "Submitted for grading" in the app
And I should find "Submission test edited offline" in the app
But I should not find "This Assignment has offline data to be synchronised." in the app

View File

@ -0,0 +1,224 @@
@mod @mod_assign @app @javascript
Feature: Test assignments navigation
Background:
Given the following "users" exist:
| username | firstname | lastname |
| teacher1 | Teacher | teacher |
| student1 | First | Student |
| student2 | Second | Student |
| student3 | Third | Student |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
| student3 | C1 | student |
And the following "groups" exist:
| name | course | idnumber |
| Group 1 | C1 | G1 |
| Group 2 | C1 | G2 |
And the following "group members" exist:
| user | group |
| student1 | G1 |
| student2 | G1 |
| student2 | G2 |
| student3 | G2 |
And the following "activities" exist:
| activity | name | course | idnumber | assignsubmission_onlinetext_enabled | duedate | groupmode |
| assign | Assignment | C1 | assignment | 1 | 0 | 1 |
And the following "mod_assign > submissions" exist:
| assign | user | onlinetext |
| assignment | student1 | Lorem |
| assignment | student3 | Ipsum |
Scenario: Mobile navigation on assignment
Given I entered the course "Course 1" as "teacher1" in the app
# Initial status
When I press "Assignment" in the app
Then I should find "3" near "Participants" in the app
And I should find "2" near "Drafts" in the app
# Participants
When I press "Participants" in the app
Then I should find "First Student" in the app
And I should find "Second Student" in the app
And I should find "Third Student" in the app
# Participants — swipe
When I press "First Student" in the app
And I swipe to the right in the app
Then I should find "First Student" in the app
But I should not find "Second Student" in the app
And I should not find "Third Student" in the app
When I swipe to the left in the app
Then I should find "Second Student" in the app
But I should not find "First Student" in the app
And I should not find "Third Student" in the app
When I swipe to the left in the app
Then I should find "Third Student" in the app
But I should not find "First Student" in the app
And I should not find "Second Student" in the app
When I swipe to the left in the app
Then I should find "Third Student" in the app
But I should not find "First Student" in the app
And I should not find "Second Student" in the app
# Drafts
When I press the back button in the app
And I press the back button in the app
And I press "Drafts" in the app
Then I should find "First Student" in the app
And I should find "Third Student" in the app
But I should not find "Second Student" in the app
# Drafts — swipe
When I press "First Student" in the app
And I swipe to the right in the app
Then I should find "First Student" in the app
But I should not find "Second Student" in the app
And I should not find "Third Student" in the app
When I swipe to the left in the app
Then I should find "Third Student" in the app
But I should not find "First Student" in the app
And I should not find "Second Student" in the app
When I swipe to the left in the app
Then I should find "Third Student" in the app
But I should not find "First Student" in the app
And I should not find "Second Student" in the app
# Filter groups in assignment page
When I press the back button in the app
And I press the back button in the app
And I press "Separate groups" in the app
And I press "Group 1" in the app
Then I should find "2" near "Participants" in the app
And I should find "1" near "Drafts" in the app
When I press "Participants" in the app
Then I should find "First Student" in the app
And I should find "Second Student" in the app
But I should not find "Third Student" in the app
When I press "First Student" in the app
And I swipe to the right in the app
Then I should find "First Student" in the app
But I should not find "Second Student" in the app
And I should not find "Third Student" in the app
When I swipe to the left in the app
Then I should find "Second Student" in the app
But I should not find "First Student" in the app
And I should not find "Third Student" in the app
When I swipe to the left in the app
Then I should find "Second Student" in the app
But I should not find "First Student" in the app
And I should not find "Third Student" in the app
# Filter groups in submissions page
When I press the back button in the app
And I press "Separate groups" in the app
And I press "Group 2" in the app
Then I should find "Second Student" in the app
And I should find "Third Student" in the app
But I should not find "First Student" in the app
When I press "Second Student" in the app
And I swipe to the right in the app
Then I should find "Second Student" in the app
But I should not find "First Student" in the app
And I should not find "Third Student" in the app
When I swipe to the left in the app
Then I should find "Third Student" in the app
But I should not find "First Student" in the app
And I should not find "Second Student" in the app
When I swipe to the left in the app
Then I should find "Third Student" in the app
But I should not find "First Student" in the app
And I should not find "Second Student" in the app
Scenario: Tablet navigation on assignment
Given I entered the course "Course 1" as "teacher1" in the app
And I change viewport size to "1200x640"
# Initial status
When I press "Assignment" in the app
Then I should find "3" near "Participants" in the app
And I should find "2" near "Drafts" in the app
# Participants
When I press "Participants" in the app
Then I should find "First Student" in the app
And I should find "Second Student" in the app
And I should find "Third Student" in the app
And "First Student" near "Third Student" should be selected in the app
And I should find "First Student" inside the split-view content in the app
But I should not find "Second Student" inside the split-view content in the app
And I should not find "Third Student" inside the split-view content in the app
# Participants — Split view
When I press "Second Student" in the app
Then "Second Student" near "Third Student" should be selected in the app
And I should find "Second Student" inside the split-view content in the app
But I should not find "First Student" inside the split-view content in the app
And I should not find "Third Student" inside the split-view content in the app
# Drafts
When I press the back button in the app
And I press "Drafts" in the app
Then I should find "First Student" in the app
And I should find "Third Student" in the app
And "First Student" near "Third Student" should be selected in the app
And I should find "First Student" inside the split-view content in the app
But I should not find "Second Student" in the app
And I should not find "Third Student" inside the split-view content in the app
# Drafts — Split view
When I press "Third Student" in the app
Then "Third Student" near "First Student" should be selected in the app
And I should find "Third Student" inside the split-view content in the app
But I should not find "First Student" inside the split-view content in the app
And I should not find "Second Student" in the app
# Filter groups in assignment page
When I press the back button in the app
And I press "Separate groups" in the app
And I press "Group 1" in the app
Then I should find "2" near "Participants" in the app
And I should find "1" near "Drafts" in the app
When I press "Participants" in the app
Then I should find "First Student" in the app
And I should find "Second Student" in the app
And "First Student" near "Second Student" should be selected in the app
And I should find "First Student" inside the split-view content in the app
But I should not find "Third Student" in the app
And I should not find "Second Student" inside the split-view content in the app
# Filter groups in submissions page
When I press "Separate groups" in the app
And I press "Group 2" in the app
Then I should find "Second Student" in the app
And I should find "Third Student" in the app
And "Second Student" near "Third Student" should be selected in the app
And I should find "Second Student" inside the split-view content in the app
But I should not find "First Student" in the app
And I should not find "Third Student" inside the split-view content in the app
When I press "Third Student" in the app
Then "Third Student" near "Second Student" should be selected in the app
And I should find "Third Student" inside the split-view content in the app
But I should not find "Second Student" inside the split-view content in the app
And I should not find "First Student" in the app

View File

@ -0,0 +1,72 @@
@mod @mod_chat @app @javascript
Feature: Test basic usage of chat in app
As a student
I need basic chat functionality to work
Background:
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username | firstname | lastname |
| student1 | david | student |
| student2 | pau | student2 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student2 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| chat | Test chat name | Test chat | C1 | chat | 0 |
Scenario: Receive and send messages & See connected users, beep and talk to
# Send messages as student1
Given I entered the chat activity "Test chat name" on course "Course 1" as "student1" in the app
Then I should find "Enter the chat" in the app
And I should find "Past sessions" in the app
When I press "Enter the chat" in the app
And I set the field "New message" to "Hi!" in the app
And I press "Send" in the app
Then I should find "Hi!" in the app
When I set the field "New message" to "I am David" in the app
And I press "Send" in the app
Then I should find "Hi!" in the app
And I should find "I am David" in the app
# Read messages, view connected users, send beep and reply as student2
Given I entered the chat activity "Test chat name" on course "Course 1" as "student2" in the app
And I press "Enter the chat" in the app
Then I should find "Hi!" in the app
And I should find "I am David" in the app
When I press "Users" in the app
Then I should find "david student" in the app
When I press "Beep" in the app
Then I should find "You beeped david student" in the app
When I set the field "New message" to "Hi David, I am Pau." in the app
And I press "Send" in the app
Then I should find "Hi David, I am Pau." in the app
Scenario: Past sessions shown
# Send messages as student1
Given I entered the chat activity "Test chat name" on course "Course 1" as "student1" in the app
When I press "Enter the chat" in the app
And I set the field "New message" to "Hi!" in the app
And I press "Send" in the app
Then I should find "Hi!" in the app
When I set the field "New message" to "I am David" in the app
And I press "Send" in the app
Then I should find "I am David" in the app
# Read messages from past sessions as student2
Given I entered the chat activity "Test chat name" on course "Course 1" as "student2" in the app
When I press "Past sessions" in the app
And I press "Show incomplete sessions" in the app
And I press "david student" near "(2)" in the app
Then I should find "Hi!" in the app
And I should find "I am David" in the app

View File

@ -0,0 +1,43 @@
@mod @mod_chat @app @javascript
Feature: Test chat navigation
Background:
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username | firstname | lastname |
| student1 | Student | first |
| student2 | Student | second |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student2 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| chat | Test chat name | Test chat | C1 | chat | 0 |
# Create sessions
# TODO use generator instead
And I entered the chat activity "Test chat name" on course "Course 1" as "student1" in the app
And I press "Enter the chat" in the app
And I set the field "New message" to "Test message" in the app
And I press "Send" in the app
Then I should find "Test message" in the app
Scenario: Tablet navigation on chat
Given I entered the course "Course 1" as "student2" in the app
And I change viewport size to "1200x640"
# Sessions
When I press "Test chat name" in the app
And I press "Past sessions" in the app
Then I should find "No sessions found" in the app
# Sessions — split view
When I press "Show incomplete sessions" in the app
Then "Student first" should be selected in the app
And I should find "Test message" in the app
When I press "Show incomplete sessions" in the app
Then I should not find "Student first" in the app
And I should not find "Test message" in the app

View File

@ -0,0 +1,41 @@
@mod @mod_choice @app @javascript @lms_upto3.11
Feature: Test basic usage of choice activity in app
In order to participate in the choice while using the mobile app
As a student
I need basic choice functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
Scenario: Download students choice in text format
# Submit answer as student
Given the following "activities" exist:
| activity | name | intro | course | idnumber | option |
| choice | Choice name | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 |
And I entered the choice activity "Choice name" on course "Course 1" as "student1" in the app
Then I select "Option 2" in the app
And I press "Save my choice" in the app
And I press "OK" in the app
# Download answers as teacher
Given I entered the choice activity "Choice name" on course "Course 1" as "teacher1" in the app
Then I should find "Test choice description" in the app
When I press "Information" 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 "teacher1"
And I press "Actions menu"
And I follow "View 1 responses"
And I press "Download in text format"
# TODO Then I should find "..." in the downloads folder

View File

@ -0,0 +1,185 @@
@mod @mod_choice @app @javascript
Feature: Test basic usage of choice activity in app
In order to participate in the choice while using the mobile app
As a student
I need basic choice functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
Scenario: Answer a choice (multi or single, update answer) & View results
Given the following "activities" exist:
| activity | name | intro | course | idnumber | option | allowmultiple | allowupdate | showresults |
| choice | Test single choice name | Test single choice description | C1 | choice1 | Option 1, Option 2, Option 3 | 0 | 0 | 1 |
And I entered the choice activity "Test single choice name" on course "Course 1" as "student1" in the app
When I select "Option 1" in the app
And I select "Option 2" in the app
And I press "Save my choice" in the app
Then I should find "Are you sure" in the app
When I press "OK" in the app
Then I should find "Option 1: 0" in the app
And I should find "Option 2: 1" in the app
And I should find "Option 3: 0" in the app
But I should not find "Remove my choice" in the app
When I press the back button in the app
And I press "Test single choice name" in the app
Then I should find "Option 1: 0" in the app
And I should find "Option 2: 1" in the app
And I should find "Option 3: 0" in the app
Scenario: Answer a choice (multi or single, update answer) & View results & Delete choice
Given the following "activities" exist:
| activity | name | intro | course | idnumber | option | allowmultiple | allowupdate | showresults |
| choice | Test multi choice name | Test multi choice description | C1 | choice2 | Option 1, Option 2, Option 3 | 1 | 1 | 1 |
And I entered the choice activity "Test multi choice name" on course "Course 1" as "student1" in the app
When I select "Option 1" in the app
And I select "Option 2" in the app
And I press "Save my choice" in the app
Then I should find "Option 1: 1" in the app
And I should find "Option 2: 1" in the app
And I should find "Option 3: 0" in the app
And I should find "Remove my choice" in the app
When I unselect "Option 1" in the app
And I select "Option 3" in the app
And I press "Save my choice" in the app
Then I should find "Option 1: 0" in the app
And I should find "Option 2: 1" in the app
And I should find "Option 3: 1" in the app
When I press "Remove my choice" in the app
Then I should find "Are you sure" in the app
When I press "Delete" in the app
Then I should find "The results are not currently viewable" in the app
But I should not find "Remove my choice" in the app
Scenario: Answer and change answer offline & Sync choice
Given the following "activities" exist:
| activity | name | intro | course | idnumber | option | allowmultiple | allowupdate | showresults |
| choice | Test single choice name | Test single choice description | C1 | choice1 | Option 1, Option 2, Option 3 | 0 | 0 | 1 |
And I entered the choice activity "Test single choice name" on course "Course 1" as "student1" in the app
When I select "Option 1" in the app
And I switch offline mode to "true"
And I select "Option 2" in the app
And I press "Save my choice" in the app
Then I should find "Are you sure" in the app
When I press "OK" in the app
And I press the back button in the app
And I press "Test single choice name" in the app
Then I should find "This Choice has offline data to be synchronised." in the app
But I should not find "Option 1: 0" in the app
And I should not find "Option 2: 1" in the app
And I should not find "Option 3: 0" in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "Test single choice name" in the app
Then I should find "Test single choice description" in the app
When I press "Information" in the app
And I press "Refresh" in the app
Then I should find "Option 1: 0" in the app
And I should find "Option 2: 1" in the app
And I should find "Option 3: 0" in the app
But I should not find "This Choice has offline data to be synchronised." in the app
Scenario: Answer and change answer offline & Auto-sync choice
Given the following "activities" exist:
| activity | name | intro | course | idnumber | option | allowmultiple | allowupdate | showresults |
| choice | Test single choice name | Test single choice description | C1 | choice1 | Option 1, Option 2, Option 3 | 0 | 0 | 1 |
And I entered the choice activity "Test single choice name" on course "Course 1" as "student1" in the app
When I select "Option 1" in the app
And I switch offline mode to "true"
And I select "Option 2" in the app
And I press "Save my choice" in the app
Then I should find "Are you sure" in the app
When I press "OK" in the app
Then I should find "This Choice has offline data to be synchronised." in the app
But I should not find "Option 1: 0" in the app
And I should not find "Option 2: 1" in the app
And I should not find "Option 3: 0" in the app
When I switch offline mode to "false"
And I run cron tasks in the app
And I wait loading to finish in the app
Then I should find "Option 1: 0" in the app
And I should find "Option 2: 1" in the app
And I should find "Option 3: 0" in the app
But I should not find "This Choice has offline data to be synchronised." in the app
Scenario: Prefetch
Given the following "activities" exist:
| activity | name | intro | course | idnumber | option | allowmultiple | allowupdate | showresults |
| choice | Test multi choice name | Test multi choice description | C1 | choice2 | Option 1, Option 2, Option 3 | 1 | 1 | 1 |
| choice | Test single choice name | Test single choice description | C1 | choice1 | Option 1, Option 2, Option 3 | 0 | 0 | 1 |
Given I entered the course "Course 1" as "student1" in the app
When I press "Course downloads" in the app
When I press "Download" within "Test single choice name" "ion-item" in the app
Then I should find "Downloaded" within "Test single choice name" "ion-item" in the app
And I press the back button in the app
When I switch offline mode to "true"
And I press "Test multi choice name" in the app
Then I should find "There was a problem connecting to the site. Please check your connection and try again." in the app
When I press "OK" in the app
And I press the back button in the app
And I press "Test single choice name" in the app
And I select "Option 2" in the app
And I press "Save my choice" in the app
Then I should find "Are you sure" in the app
When I press "OK" in the app
And I press the back button in the app
And I press "Test single choice name" in the app
Then I should find "This Choice has offline data to be synchronised." in the app
But I should not find "Option 1: 0" in the app
And I should not find "Option 2: 1" in the app
And I should not find "Option 3: 0" in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "Test single choice name" in the app
Then I should find "Option 1: 0" in the app
And I should find "Option 2: 1" in the app
And I should find "Option 3: 0" in the app
But I should not find "This Choice has offline data to be synchronised." in the app
# TODO remove LMS UI steps in app tests
@lms_from4.0
Scenario: Download students choice in text format
# Submit answer as student
Given the following "activities" exist:
| activity | name | intro | course | idnumber | option |
| choice | Choice name | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 |
And I entered the choice activity "Choice name" on course "Course 1" as "student1" in the app
When I select "Option 2" in the app
And I press "Save my choice" in the app
And I press "OK" in the app
# Download answers as teacher
Given I entered the choice activity "Choice name" on course "Course 1" as "teacher1" in the app
Then I should find "Test choice description" in the app
When I press "Information" 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 "teacher1"
And I follow "Responses"
And I press "Download in text format"
# TODO Then I should find "..." in the downloads folder

View File

@ -0,0 +1,379 @@
@mod @mod_forum @app @javascript
Feature: Test basic usage of forum activity in app
In order to participate in the forum while using the mobile app
As a student
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 |
| student2 |
| teacher1 |
| teacher2 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student2 | C1 | student |
| teacher1 | C1 | editingteacher |
| teacher2 | C1 | editingteacher |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | assessed | scale |
| forum | Test forum name | Test forum | C1 | forum | 0 | 1 | 1 |
Scenario: Create new discussion
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add 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 find "My happy subject" in the app
When I press "My happy subject" in the app
Then I should find "An awesome message" in the app
Scenario: Reply a post
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add 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
Then I should find "Reply" in the app
When 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 find "DiscussionMessage" in the app
And I should find "ReplyMessage" in the app
Scenario: Star and pin discussions (student)
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app
And I set the field "Subject" to "starred subject" in the app
And I set the field "Message" to "starred message" in the app
And I press "Post to forum" in the app
And I press "Add discussion topic" in the app
And I set the field "Subject" to "normal subject" in the app
And I set the field "Message" to "normal message" in the app
And I press "Post to forum" in the app
And I press "starred subject" in the app
Then I should find "starred message" in the app
When I press the back button in the app
And I press "Display options" near "starred subject" in the app
And I press "Star this discussion" in the app
And I press "starred subject" in the app
Then I should find "starred message" in the app
When I press the back button in the app
And I press "normal subject" in the app
Then I should find "normal message" in the app
When I press the back button in the app
And I press "Display options" near "starred subject" in the app
And I press "Unstar this discussion" in the app
And I press "starred subject" in the app
Then I should find "starred message" in the app
When I press the back button in the app
And I press "normal subject" in the app
Then I should find "normal message" in the app
Scenario: Star and pin discussions (teacher)
Given I entered the forum activity "Test forum name" on course "Course 1" as "teacher1" in the app
When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test star" in the app
And I set the field "Message" to "Auto-test star message" in the app
And I press "Post to forum" in the app
And I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test pin" in the app
And I set the field "Message" to "Auto-test pin message" in the app
And I press "Post to forum" in the app
And I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test plain" in the app
And I set the field "Message" to "Auto-test plain message" in the app
And I press "Post to forum" in the app
And I press "Display options" near "Auto-test star" in the app
And I press "Star this discussion" in the app
And I press "Display options" near "Auto-test pin" in the app
And I press "Pin this discussion" in the app
Then I should find "Auto-test pin" in the app
And I should find "Auto-test star" in the app
And I should find "Auto-test plain" in the app
When I press "Display options" near "Auto-test pin" in the app
And I press "Unpin this discussion" in the app
And I press "Display options" near "Auto-test star" in the app
And I press "Unstar this discussion" in the app
Then I should find "Auto-test star" in the app
And I should find "Auto-test pin" in the app
Scenario: Edit a not sent reply offline
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I press "Post to forum" in the app
And I press "Auto-test" near "Sort by last post creation date in descending order" in the app
And I should find "Reply" in the app
When I press the back button in the app
And I switch offline mode to "true"
And I press "Auto-test" near "Sort by last post creation date in descending order" in the app
Then I should find "Reply" in the app
When I press "Reply" in the app
And I set the field "Message" to "not sent reply" in the app
And I press "Post to forum" in the app
And I press "Display options" within "not sent reply" "ion-card" in the app
Then I should find "Edit" in the app
When I press "Edit" in the app
And I set the field "Message" to "not sent reply edited" in the app
And I press "Save changes" in the app
Then I should find "Not sent" in the app
And I should find "This Discussion has offline data to be synchronised" in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "Auto-test" near "Sort by last post creation date in descending order" in the app
Then I should not find "Not sent" in the app
And I should not find "This Discussion has offline data to be synchronised" in the app
Scenario: Edit a not sent new discussion offline
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I switch offline mode to "true"
And I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I press "Post to forum" in the app
And I press "Auto-test" in the app
And I set the field "Message" to "Auto-test message edited" in the app
And I press "Post to forum" in the app
Then I should find "This Forum has offline data to be synchronised." in the app
When I switch offline mode to "false"
And I press "Auto-test" in the app
Then I should find "Post to forum" in the app
When I press "Post to forum" in the app
Then I should not find "This Forum has offline data to be synchronised." in the app
When I press "Auto-test" near "Sort by last post creation date in descending order" in the app
And I should find "Auto-test message edited" in the app
Scenario: Edit a forum post (only online)
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I press "Post to forum" in the app
Then I should find "Auto-test" in the app
When I press the back button in the app
And I press "Course downloads" in the app
And I press "Download" within "Test forum name" "ion-item" in the app
And I press the back button in the app
And I press "Test forum name" in the app
And I press "Auto-test" near "Sort by last post creation date in descending order" in the app
Then I should find "Reply" in the app
When I press "Display options" near "Reply" in the app
Then I should find "Edit" in the app
When I press "Edit" in the app
And I switch offline mode to "true"
And I set the field "Message" to "Auto-test message edited" in the app
And I press "Save changes" in the app
Then I should find "There was a problem connecting to the site. Please check your connection and try again." in the app
# TODO Fix this test in all Moodle versions
Scenario: Delete a forum post (only online)
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I press "Post to forum" in the app
Then I should find "Auto-test" in the app
When I press the back button in the app
And I press "Course downloads" in the app
And I press "Download" within "Test forum name" "ion-item" in the app
And I press the back button in the app
And I press "Test forum name" in the app
And I press "Auto-test" near "Sort by last post creation date in descending order" in the app
Then I should find "Reply" in the app
When I press "Display options" near "Reply" in the app
Then I should find "Delete" in the app
When I press "Delete" in the app
And I press "Cancel" in the app
And I switch offline mode to "true"
And I press "Display options" near "Reply" in the app
Then I should not find "Delete" in the app
When I close the popup in the app
And I switch offline mode to "false"
And I press "Display options" near "Reply" in the app
And I press "Delete" in the app
And I press "Delete" in the app
Then I should not find "Auto-test" in the app
Scenario: Add/view ratings
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app
And I set the field "Subject" to "Auto-test" in the app
And I set the field "Message" to "Auto-test message" in the app
And I press "Post to forum" in the app
And I press "Auto-test" in the app
Then I should find "Reply" in the app
When I press "Reply" in the app
And I set the field "Message" to "test2" in the app
And I press "Post to forum" in the app
Then I should find "test2" "ion-card" in the app
Given I entered the forum activity "Test forum name" on course "Course 1" as "teacher1" in the app
When I press "Auto-test" in the app
Then I should find "Reply" in the app
When I press "None" near "Auto-test message" in the app
And I press "1" near "Cancel" in the app
And I switch offline mode to "true"
And I press "None" near "test2" in the app
And I press "0" near "Cancel" in the app
Then I should find "Data stored in the device because it couldn't be sent. It will be sent automatically later." inside the toast in the app
And I should find "Average of ratings: -" in the app
And I should find "Average of ratings: 1" in the app
When I switch offline mode to "false"
And I press the back button in the app
Then I should find "This Forum has offline data to be synchronised." in the app
When I press "Information" in the app
And I press "Synchronise now" in the app
Then I should not find "This Forum has offline data to be synchronised." in the app
When I press "Auto-test" in the app
Then I should find "Average of ratings: 1" in the app
And I should find "Average of ratings: 0" in the app
But I should not find "Average of ratings: -" in the app
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Auto-test" in the app
Then I should find "Average of ratings: 1" in the app
And I should find "Average of ratings: 0" in the app
But I should not find "Average of ratings: -" in the app
Scenario: Reply a post offline
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add 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 the back button in the app
And I press "Course downloads" in the app
And I press "Download" within "Test forum name" "ion-item" in the app
And I press the back button in the app
And I press "Test forum name" in the app
And I press "DiscussionSubject" in the app
And I switch offline mode to "true"
Then I should find "Reply" in the app
When 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 find "DiscussionMessage" in the app
And I should find "ReplyMessage" in the app
And I should find "Not sent" in the app
When I press the back button in the app
And I switch offline mode to "false"
And I press "DiscussionSubject" in the app
Then I should find "DiscussionMessage" in the app
And I should find "ReplyMessage" in the app
But I should not find "Not sent" in the app
Scenario: New discussion offline & Sync Forum
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I switch offline mode to "true"
And I press "Add 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
Then I should find "DiscussionSubject" in the app
And I should find "Not sent" in the app
And I should find "This Forum has offline data to be synchronised." in the app
When I switch offline mode to "false"
And I press the back button in the app
And I press "Test forum name" in the app
And I press "Information" in the app
And I press "Refresh" in the app
And I press "DiscussionSubject" near "Sort by last post creation date in descending order" in the app
Then I should find "DiscussionSubject" in the app
And I should find "DiscussionMessage" in the app
But I should not find "Not sent" in the app
And I should not find "This Forum has offline data to be synchronised." in the app
Scenario: New discussion offline & Auto-sync forum
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I switch offline mode to "true"
And I press "Add 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
Then I should find "DiscussionSubject" in the app
And I should find "Not sent" in the app
And I should find "This Forum has offline data to be synchronised." in the app
When I switch offline mode to "false"
And I run cron tasks in the app
And I wait loading to finish in the app
Then I should not find "Not sent" in the app
When I press "DiscussionSubject" near "Sort by last post creation date in descending order" in the app
Then I should find "DiscussionSubject" in the app
And I should find "DiscussionMessage" in the app
But I should not find "Not sent" in the app
And I should not find "This Forum has offline data to be synchronised." in the app
Scenario: Prefetch
Given I entered the forum activity "Test forum name" on course "Course 1" as "student1" in the app
When I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject 1" in the app
And I set the field "Message" to "DiscussionMessage 1" in the app
And I press "Post to forum" in the app
Then I should find "DiscussionSubject 1" in the app
When I press the back button in the app
And I press "Course downloads" in the app
And I press "Download" within "Test forum name" "ion-item" in the app
Then I should find "Downloaded" within "Test forum name" "ion-item" in the app
And I press the back button in the app
When I press "Test forum name" in the app
And I press "Add discussion topic" in the app
And I set the field "Subject" to "DiscussionSubject 2" in the app
And I set the field "Message" to "DiscussionMessage 2" in the app
And I press "Post to forum" in the app
Then I should find "DiscussionSubject 1" in the app
And I should find "DiscussionSubject 2" in the app
When I press the back button in the app
And I switch offline mode to "true"
And I press "Test forum name" in the app
And I press "DiscussionSubject 2" in the app
Then I should find "There was a problem connecting to the site. Please check your connection and try again." in the app
When I press "OK" in the app
And I press the back button in the app
And I press "DiscussionSubject 1" in the app
Then I should find "DiscussionSubject 1" in the app
And I should find "DiscussionMessage 1" in the app
But I should not find "There was a problem connecting to the site. Please check your connection and try again." in the app

View File

@ -0,0 +1,231 @@
@mod @mod_forum @app @javascript
Feature: Test forum navigation
Background:
Given the following "users" exist:
| username | firstname | lastname |
| student1 | First | Student |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | course | idnumber |
| forum | Forum | C1 | forum |
And the following "mod_forum > discussions" exist:
| forum | name | message | timenow |
| forum | Discussion 01 | Discussion 01 message | 1638200100 |
| forum | Discussion 02 | Discussion 02 message | 1638200200 |
| forum | Discussion 03 | Discussion 03 message | 1638200300 |
| forum | Discussion 04 | Discussion 04 message | 1638200400 |
| forum | Discussion 05 | Discussion 05 message | 1638200500 |
| forum | Discussion 06 | Discussion 06 message | 1638200600 |
| forum | Discussion 07 | Discussion 07 message | 1638200700 |
| forum | Discussion 08 | Discussion 08 message | 1638200800 |
| forum | Discussion 09 | Discussion 09 message | 1638200900 |
| forum | Discussion 10 | Discussion 10 message | 1638201000 |
| forum | Discussion 11 | Discussion 11 message | 1638201100 |
| forum | Discussion 12 | Discussion 12 message | 1638201200 |
| forum | Discussion 13 | Discussion 13 message | 1638201300 |
| forum | Discussion 14 | Discussion 14 message | 1638201400 |
| forum | Discussion 15 | Discussion 15 message | 1638201500 |
| forum | Discussion 16 | Discussion 16 message | 1638201600 |
| forum | Discussion 17 | Discussion 17 message | 1638201700 |
| forum | Discussion 18 | Discussion 18 message | 1638201800 |
| forum | Discussion 19 | Discussion 19 message | 1638201900 |
| forum | Discussion 20 | Discussion 20 message | 1638202000 |
And the following "mod_forum > posts" exist:
| discussion | parentsubject | message |
| Discussion 04 | Discussion 04 | Discussion 04 first reply |
| Discussion 05 | Discussion 05 | Discussion 05 first reply |
Scenario: Mobile navigation on forum
Given I entered the course "Course 1" as "student1" in the app
# By last reply
When I press "Forum" in the app
Then I should find "Discussion 05" in the app
And I should find "Discussion 04" in the app
But I should not find "Discussion 12" in the app
# By last reply — Infinite loading
When I load more items in the app
Then I should find "Discussion 12" in the app
And I should find "Discussion 01" in the app
But I should not be able to load more items in the app
# By last reply — Swipe
When I press "Discussion 05" in the app
Then I should find "Discussion 05 first reply" in the app
When I swipe to the right in the app
Then I should find "Discussion 05 first reply" in the app
When I swipe to the left in the app
Then I should find "Discussion 04 first reply" in the app
When I swipe to the left in the app
Then I should find "Discussion 20 message" in the app
# By creation date
When I press the back button in the app
And I scroll to "Discussion 05" in the app
And I press "Sort" in the app
And I press "Sort by creation date in descending order" in the app
Then I should find "Discussion 20" in the app
And I should find "Discussion 19" in the app
But I should not find "Discussion 10" in the app
And I should not find "Discussion 04" in the app
And I should not find "Discussion 05" in the app
# By creation date — Infinite loading
When I load more items in the app
Then I should find "Discussion 10" in the app
And I should find "Discussion 04" in the app
And I should find "Discussion 05" in the app
But I should not be able to load more items in the app
# By creation date — Swipe
When I press "Discussion 20" in the app
Then I should find "Discussion 20 message" in the app
When I swipe to the right in the app
Then I should find "Discussion 20 message" in the app
When I swipe to the left in the app
Then I should find "Discussion 19 message" in the app
When I swipe to the left in the app
Then I should find "Discussion 18 message" in the app
# Offline
When I press the back button in the app
And I press "Add discussion topic" in the app
And I switch offline mode to "true"
And I set the field "Subject" to "Offline discussion 1" in the app
And I set the field "Message" to "Offline discussion 1 message" in the app
And I press "Post to forum" in the app
And I press "Add discussion topic" in the app
And I set the field "Subject" to "Offline discussion 2" in the app
And I set the field "Message" to "Offline discussion 2 message" in the app
And I press "Post to forum" in the app
Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app
And I should find "Offline discussion 2" in the app
When I press "Offline discussion 2" in the app
And I set the field "Subject" to "Offline discussion 3" in the app
And I set the field "Message" to "Offline discussion 3 message" in the app
And I press "Post to forum" in the app
Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app
And I should find "Offline discussion 3" in the app
But I should not find "Offline discussion 2" in the app
# TODO fix flaky test failing on CI but working locally
# # Offline — Swipe
# When I press "Offline discussion 3" in the app
# Then I should find "Offline discussion 3 message" in the app
# When I swipe to the right in the app
# Then I should find "Offline discussion 3 message" in the app
# When I swipe to the left in the app
# Then I should find "Offline discussion 1 message" in the app
# When I swipe to the left in the app
# Then I should find "Discussion 20 message" in the app
Scenario: Tablet navigation on forum
Given I entered the course "Course 1" as "student1" in the app
And I change viewport size to "1200x640"
# By last reply
When I press "Forum" in the app
Then I should find "Discussion 04" in the app
And I should find "Discussion 05" in the app
And "Discussion 05" near "Discussion 04" should be selected in the app
And I should find "Discussion 05 first reply" inside the split-view content in the app
But I should not find "Discussion 12" in the app
# By last reply — Infinite loading
When I load more items in the app
Then I should find "Discussion 12" in the app
And I should find "Discussion 01" in the app
But I should not be able to load more items in the app
# By last reply — Split view
When I press "Discussion 04" in the app
Then "Discussion 04" near "Discussion 05" should be selected in the app
And I should find "Discussion 04 first reply" inside the split-view content in the app
When I press "Discussion 12" in the app
Then "Discussion 12" near "Discussion 11" should be selected in the app
And I should find "Discussion 12 message" inside the split-view content in the app
# By creation date
When I scroll to "Discussion 05" in the app
And I press "Discussion 05" in the app
And I press "Sort" in the app
And I press "Sort by creation date in descending order" in the app
Then I should find "Discussion 20" in the app
And I should find "Discussion 19" in the app
And "Discussion 20" near "Discussion 19" should be selected in the app
And I should find "Discussion 20 message" inside the split-view content in the app
But I should not find "Discussion 10" in the app
And I should not find "Discussion 04" in the app
And I should not find "Discussion 05" in the app
# By creation date — Infinite loading
When I load more items in the app
Then I should find "Discussion 10" in the app
And I should find "Discussion 04" in the app
And I should find "Discussion 05" in the app
But I should not be able to load more items in the app
# By creation date — Split view
When I press "Discussion 19" in the app
Then "Discussion 19" near "Discussion 20" should be selected in the app
And I should find "Discussion 19 message" inside the split-view content in the app
When I press "Discussion 05" in the app
Then "Discussion 05" near "Discussion 04" should be selected in the app
And I should find "Discussion 05 first reply" inside the split-view content in the app
# Offline
When I press "Add discussion topic" in the app
And I switch offline mode to "true"
And I set the field "Subject" to "Offline discussion 1" in the app
And I set the field "Message" to "Offline discussion 1 message" in the app
And I press "Post to forum" in the app
And I press "Add discussion topic" in the app
And I set the field "Subject" to "Offline discussion 2" in the app
And I set the field "Message" to "Offline discussion 2 message" in the app
And I press "Post to forum" in the app
Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app
And I should find "Offline discussion 2" in the app
When I press "Offline discussion 2" in the app
And I set the field "Subject" to "Offline discussion 3" in the app
And I set the field "Message" to "Offline discussion 3 message" in the app
And I press "Post to forum" in the app
Then I should find "Not sent" in the app
And I should find "Offline discussion 1" in the app
And I should find "Offline discussion 3" in the app
But I should not find "Offline discussion 2" in the app
# Offline — Split view
When I press "Offline discussion 1" in the app
Then "Offline discussion 1" near "Offline discussion 3" should be selected in the app
And I should find "Offline discussion 1 message" inside the split-view content in the app
When I press "Offline discussion 3" in the app
Then "Offline discussion 3" near "Offline discussion 1" should be selected in the app
And I should find "Offline discussion 3 message" inside the split-view content in the app
When I press "Discussion 20" in the app
Then "Discussion 20" near "Discussion 19" should be selected in the app
And I should find "Discussion 20 message" inside the split-view content in the app

View File

@ -0,0 +1,289 @@
@mod @mod_glossary @app @javascript
Feature: Test glossary navigation
Background:
Given the following "users" exist:
| username | firstname | lastname |
| student1 | First | Student |
| student2 | Second | Student |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | course | idnumber | displayformat |
| glossary | Fruits glossary | C1 | glossary | entrylist |
And the following "mod_glossary > entries" exist:
| glossary | concept | definition | user |
| glossary | Acerola | Acerola is a fruit | student1 |
| glossary | Apple | Apple is a fruit | student2 |
| glossary | Apricots | Apricots are a fruit | student1 |
| glossary | Avocado | Avocado is a fruit | student2 |
| glossary | Banana | Banana is a fruit | student1 |
| glossary | Blackberries | Blackberries is a fruit | student2 |
| glossary | Blackcurrant | Blackcurrant is a fruit | student1 |
| glossary | Blueberries | Blueberries is a fruit | student2 |
| glossary | Breadfruit | Breadfruit is a fruit | student1 |
| glossary | Cantaloupe | Cantaloupe is a fruit | student2 |
| glossary | Carambola | Carambola is a fruit | student1 |
| glossary | Cherimoya | Cherimoya is a fruit | student2 |
| glossary | Cherries | Cherries is a fruit | student1 |
| glossary | Clementine | Clementine is a fruit | student2 |
| glossary | Coconut | Coconut is a fruit | student1 |
| glossary | Cranberries | Cranberries is a fruit | student2 |
| glossary | Date Fruit | Date Fruit is a fruit | student1 |
| glossary | Durian | Durian is a fruit | student2 |
| glossary | Elderberries | Elderberries is a fruit | student1 |
| glossary | Feijoa | Feijoa is a fruit | student2 |
| glossary | Figs | Figs is a fruit | student1 |
| glossary | Gooseberries | Gooseberries are a fruit | student2 |
| glossary | Grapefruit | Grapefruit is a fruit | student1 |
| glossary | Grapes | Grapes are a fruit | student2 |
| glossary | Guava | Guava is a fruit | student1 |
| glossary | Honeydew Melon | Honeydew Melon is a fruit | student2 |
| glossary | Jackfruit | Jackfruit is a fruit | student1 |
| glossary | Java-Plum | Java-Plum is a fruit | student2 |
| glossary | Jujube Fruit | Jujube Fruit is a fruit | student1 |
| glossary | Kiwifruit | Kiwifruit is a fruit | student2 |
| glossary | Kumquat | Kumquat is a fruit | student1 |
| glossary | Lemon | Lemon is a fruit | student2 |
| glossary | lime | lime is a fruit | student1 |
| glossary | Lime | Lime is a fruit | student2 |
| glossary | Longan | Longan is a fruit | student1 |
| glossary | Loquat | Loquat is a fruit | student2 |
| glossary | Lychee | Lychee is a fruit | student1 |
| glossary | Mandarin | Mandarin is a fruit | student2 |
| glossary | Mango | Mango is a fruit | student1 |
| glossary | Mangosteen | Mangosteen is a fruit | student2 |
| glossary | Mulberries | Mulberries are a fruit | student1 |
| glossary | Nectarine | Nectarine is a fruit | student2 |
| glossary | Olives | Olives are a fruit | student1 |
| glossary | Orange | Orange is a fruit | student2 |
| glossary | Papaya | Papaya is a fruit | student1 |
| glossary | Passion Fruit | Passion Fruit is a fruit | student2 |
| glossary | Peaches | Peaches is a fruit | student1 |
| glossary | Pear | Pear is a fruit | student2 |
| glossary | Persimmon | Persimmon is a fruit | student1 |
| glossary | Pitaya | Pitaya is a fruit | student2 |
| glossary | Pineapple | Pineapple is a fruit | student1 |
| glossary | Pitanga | Pitanga is a fruit | student2 |
| glossary | Plantain | Plantain is a fruit | student1 |
| glossary | Plums | Plums are a fruit | student2 |
| glossary | Pomegranate | Pomegranate is a fruit | student1 |
| glossary | Prickly Pear | Prickly Pear is a fruit | student2 |
| glossary | Prunes | Prunes is a fruit | student1 |
| glossary | Pummelo | Pummelo is a fruit | student2 |
| glossary | Quince | Quince is a fruit | student1 |
| glossary | Raspberries | Raspberries are a fruit | student2 |
| glossary | Rhubarb | Rhubarb is a fruit | student1 |
| glossary | Rose-Apple | Rose-Apple is a fruit | student2 |
| glossary | Sapodilla | Sapodilla is a fruit | student1 |
| glossary | Sapote, Mamey | Sapote, Mamey is a fruit | student2 |
| glossary | Soursop | Soursop is a fruit | student1 |
| glossary | Strawberries | Strawberries is a fruit | student2 |
| glossary | Tamarind | Tamarind is a fruit | student2 |
| glossary | Tangerine | Tangerine is a fruit | student1 |
| glossary | Watermelon | Watermelon is a fruit | student2 |
Scenario: Mobile navigation on glossary
Given I entered the course "Course 1" as "student1" in the app
# Alphabetically
When I press "Fruits glossary" in the app
Then I should find "Acerola" in the app
And I should find "Apple" in the app
But I should not find "Honeydew Melon" in the app
# Alphabetically — Infinite loading
When I load more items in the app
Then I should find "Honeydew Melon" in the app
# Alphabetically — Swipe
When I press "Acerola" in the app
Then I should find "Acerola is a fruit" in the app
When I swipe to the right in the app
Then I should find "Acerola is a fruit" in the app
When I swipe to the left in the app
Then I should find "Apple is a fruit" in the app
When I swipe to the left in the app
Then I should find "Apricots are a fruit" in the app
# By author
When I press the back button in the app
And I scroll to "Acerola" in the app
And I press "Browse entries" in the app
And I press "Group by author" in the app
Then I should find "First Student" in the app
And I should find "Acerola" in the app
And I should find "Apricots" in the app
But I should not find "Second Student" in the app
And I should not find "Apple" in the app
# By author — Infinite loading
When I load more items in the app
Then I should find "Second Student" in the app
And I should find "Apple" in the app
# By author — Swipe
When I press "Acerola" in the app
Then I should find "Acerola is a fruit" in the app
When I swipe to the right in the app
Then I should find "Acerola is a fruit" in the app
When I swipe to the left in the app
Then I should find "Apricots are a fruit" in the app
When I swipe to the left in the app
Then I should find "Banana is a fruit" in the app
# Search
When I press the back button in the app
And I scroll to "Acerola" in the app
And I press "Search" in the app
And I set the field "Search" to "melon" in the app
And I press "Search" "button" near "Clear search" in the app
Then I should find "Honeydew Melon" in the app
And I should find "Watermelon" in the app
But I should not find "Acerola" in the app
# Search — Swipe
When I press "Honeydew Melon" in the app
Then I should find "Honeydew Melon is a fruit" in the app
When I swipe to the right in the app
Then I should find "Honeydew Melon is a fruit" in the app
When I swipe to the left in the app
Then I should find "Watermelon is a fruit" in the app
When I swipe to the left in the app
Then I should find "Watermelon is a fruit" in the app
# Offline
When I press the back button in the app
And I press "Clear search" in the app
And I press "Add a new entry" in the app
And I switch offline mode to "true"
And I set the field "Concept" to "Tomato" in the app
And I set the field "Definition" to "Tomato is a fruit" in the app
And I press "Save" in the app
And I press "Add a new entry" in the app
And I set the field "Concept" to "Cashew" in the app
And I set the field "Definition" to "Cashew is a fruit" in the app
And I press "Save" in the app
Then I should find "Entries to be synced" in the app
And I should find "Tomato" in the app
And I should find "Cashew" in the app
# Offline — Swipe
When I press "Cashew" in the app
Then I should find "Cashew is a fruit" in the app
When I swipe to the right in the app
Then I should find "Cashew is a fruit" in the app
When I swipe to the left in the app
Then I should find "Tomato is a fruit" in the app
When I swipe to the left in the app
Then I should find "Acerola is a fruit" in the app
Scenario: Tablet navigation on glossary
Given I entered the course "Course 1" as "student1" in the app
And I change viewport size to "1200x640"
# Alphabetically
When I press "Fruits glossary" in the app
Then I should find "Acerola" in the app
And I should find "Apple" in the app
And "Acerola" near "Apple" should be selected in the app
And I should find "Acerola is a fruit" inside the split-view content in the app
But I should not find "Honeydew Melon" in the app
# Alphabetically — Infinite loading
When I load more items in the app
Then I should find "Honeydew Melon" in the app
# Alphabetically — Split view
When I press "Apple" in the app
Then "Apple" near "Acerola" should be selected in the app
And I should find "Apple is a fruit" inside the split-view content in the app
When I press "Honeydew Melon" in the app
Then "Honeydew Melon" near "Guava" should be selected in the app
And I should find "Honeydew Melon is a fruit" inside the split-view content in the app
# By author
When I press "Apple" in the app
When I scroll to "Apple" in the app
And I press "Browse entries" in the app
And I press "Group by author" in the app
Then I should find "First Student" in the app
And I should find "Acerola" in the app
And I should find "Apricots" in the app
And "Acerola" near "Apricots" should be selected in the app
And I should find "Acerola is a fruit" inside the split-view content in the app
But I should not find "Second Student" in the app
And I should not find "Apple" in the app
# By author — Infinite loading
When I load more items in the app
Then I should find "Second Student" in the app
And I should find "Apple" in the app
# By author — Split view
When I press "Apricots" in the app
And "Apricots" near "Acerola" should be selected in the app
And I should find "Apricots are a fruit" inside the split-view content in the app
When I press "Apple" in the app
And "Apple" near "Persimmon" should be selected in the app
And I should find "Apple is a fruit" inside the split-view content in the app
# Search
When I press "Search" in the app
And I set the field "Search" to "melon" in the app
And I press "Search" "button" near "Clear search" in the app
Then I should find "Honeydew Melon" in the app
And I should find "Watermelon" in the app
And "Honeydew Melon" near "Watermelon" should be selected in the app
And I should find "Honeydew Melon is a fruit" inside the split-view content in the app
But I should not find "Acerola" in the app
# Search — Split view
When I press "Watermelon" in the app
Then "Watermelon" near "Honeydew Melon" should be selected in the app
And I should find "Watermelon is a fruit" inside the split-view content in the app
# Offline
When I press "Clear search" in the app
And I press "Add a new entry" in the app
And I switch offline mode to "true"
And I set the field "Concept" to "Tomato" in the app
And I set the field "Definition" to "Tomato is a fruit" in the app
And I press "Save" in the app
And I set the field "Concept" to "Cashew" in the app
And I set the field "Definition" to "Cashew is a fruit" in the app
And I press "Save" in the app
Then I should find "Entries to be synced" in the app
And I should find "Tomato" in the app
And I should find "Cashew" in the app
# Offline — Split view
When I press "Cashew" in the app
Then "Cashew" near "Tomato" should be selected in the app
And I should find "Cashew is a fruit" inside the split-view content in the app
When I press "Tomato" in the app
Then "Tomato" near "Cashew" should be selected in the app
And I should find "Tomato is a fruit" inside the split-view content in the app
When I press "Acerola" in the app
Then "Acerola" near "Tomato" should be selected in the app
And I should find "Acerola is a fruit" inside the split-view content in the app

View File

@ -0,0 +1,162 @@
@mod @mod_quiz @app @javascript
Feature: Attempt a quiz in app
As a student
In order to demonstrate what I know
I need to be able to attempt quizzes
Background:
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username |
| student1 |
| teacher1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| teacher1 | C1 | editingteacher |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | TF1 | Text of the first question |
| Test questions | truefalse | TF2 | Text of the second question |
And quiz "Quiz 1" contains the following questions:
| question | page |
| TF1 | 1 |
| TF2 | 2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions 2|
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | multichoice | TF3 | Text of the first question |
| Test questions | shortanswer | TF4 | Text of the second question |
| Test questions | numerical | TF5 | Text of the third question |
| Test questions | essay | TF6 | Text of the fourth question |
| Test questions | ddwtos | TF7 | The [[1]] brown [[2]] jumped over the [[3]] dog. |
| Test questions | truefalse | TF8 | Text of the sixth question |
| Test questions | match | TF9 | Text of the seventh question |
And quiz "Quiz 2" contains the following questions:
| question | page |
| TF3 | 1 |
| TF4 | 2 |
| TF5 | 3 |
| TF6 | 4 |
| TF7 | 5 |
| TF8 | 6 |
| TF9 | 7 |
Scenario: View a quiz entry page (attempts, status, etc.)
Given I entered the quiz activity "Quiz 1" on course "Course 1" as "student1" in the app
When I press "Attempt quiz now" in the app
Then I should find "Text of the first question" in the app
But I should not find "Text of the second question" in the app
When I press "Next" near "Question 1" in the app
Then I should find "Text of the second question" in the app
But I should not find "Text of the first question" in the app
When I press "Previous" near "Question 2" in the app
Then I should find "Text of the first question" in the app
But I should not find "Text of the second question" in the app
When I press "Next" near "Quiz 1" in the app
Then I should find "Text of the second question" in the app
But I should not find "Text of the first question" in the app
When I press "Previous" near "Quiz 1" in the app
Then I should find "Text of the first question" in the app
But I should not find "Text of the second question" in the app
When I press "Next" near "Question 1" in the app
And I press "Submit" near "Quiz 1" in the app
Then I should find "Summary of attempt" in the app
When I press "Not yet answered" within "2" "ion-item" in the app
Then I should find "Text of the second question" in the app
But I should not find "Text of the first question" in the app
When I press "Submit" in the app
And I press "Submit all and finish" in the app
Then I should find "Once you submit" in the app
When I press "Cancel" near "Once you submit" in the app
Then I should find "Summary of attempt" in the app
When I press "Submit all and finish" in the app
And I press "OK" near "Once you submit" in the app
Then I should find "Review" in the app
And I should find "Started on" in the app
And I should find "State" in the app
And I should find "Completed on" in the app
And I should find "Time taken" in the app
And I should find "Marks" in the app
And I should find "Grade" in the app
And I should find "Question 1" in the app
And I should find "Question 2" in the app
Scenario: Attempt a quiz (all question types)
Given I entered the quiz activity "Quiz 2" on course "Course 1" as "student1" in the app
When I press "Attempt quiz now" in the app
And I press "Four" in the app
And I press "Three" in the app
And I press "Next" near "Quiz 2" in the app
And I set the field "Answer" to "testing" in the app
And I press "Next" near "Question 2" in the app
And I set the field "Answer" to "5" in the app
And I press "Next" near "Question 3" in the app
And I set the field "Answer" to "Testing an essay" in the app
And I press "Next" "ion-button" near "Question 4" in the app
And I press "quick" ".drag" in the app
And I press "" ".place1.drop" in the app
And I press "fox" ".drag" in the app
And I press "" ".place2.drop" in the app
And I press "lazy" ".drag" in the app
And I press "" ".place3.drop" in the app
And I press "Next" near "Question 5" in the app
And I press "True" in the app
And I press "Next" near "Question 6" in the app
And I press "Choose... , frog" in the app
And I press "amphibian" in the app
And I press "Choose... , newt" in the app
And I press "insect" in the app
And I press "Choose... , cat" in the app
And I press "mammal" in the app
And I press "Submit" near "Question 7" in the app
Then I should not find "Not yet answered" in the app
When I press "Submit all and finish" in the app
And I press "OK" in the app
Then I should find "Review" in the app
And I should find "Finished" in the app
And I should find "Not yet graded" in the app
Scenario: Submit a quiz & Review a quiz attempt
Given I entered the quiz activity "Quiz 1" on course "Course 1" as "student1" in the app
When I press "Attempt quiz now" in the app
And I press "True" in the app
And I press "Next" near "Question 1" in the app
And I press "False" in the app
And I press "Submit" near "Question 2" in the app
And I press "Submit all and finish" in the app
And I press "OK" in the app
Then I should find "Review" in the app
Given I entered the quiz activity "Quiz 1" on course "Course 1" as "teacher1" in the app
When I press "Information" 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 "teacher1"
And I follow "Attempts: 1"
And I follow "Review attempt"
Then I should see "Finished"
And I should see "1.00/2.00"

View File

@ -0,0 +1,71 @@
@mod @mod_quiz @app @javascript
Feature: Attempt a quiz in app
As a student
In order to demonstrate what I know
I need to be able to attempt quizzes
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 |
| quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | TF1 | Text of the first question |
| Test questions | truefalse | TF2 | Text of the second question |
And quiz "Quiz 1" contains the following questions:
| question | page |
| TF1 | 1 |
| TF2 | 2 |
Scenario: Next and previous navigation
Given I entered the quiz activity "Quiz 1" on course "Course 1" as "student1" in the app
And I press "Attempt quiz now" in the app
Then I should find "Text of the first question" in the app
But I should not find "Text of the second question" in the app
When I press "Next" near "Question 1" in the app
Then I should find "Text of the second question" in the app
But I should not find "Text of the first question" in the app
When I press "Previous" near "Question 2" in the app
Then I should find "Text of the first question" in the app
But I should not find "Text of the second question" in the app
When I press "Next" near "Quiz 1" in the app
Then I should find "Text of the second question" in the app
But I should not find "Text of the first question" in the app
When I press "Previous" near "Quiz 1" in the app
Then I should find "Text of the first question" in the app
But I should not find "Text of the second question" in the app
When I press "Next" near "Question 1" in the app
And I press "Submit" in the app
Then I should find "Summary of attempt" in the app
When I press "Not yet answered" within "2" "ion-item" in the app
Then I should find "Text of the second question" in the app
But I should not find "Text of the first question" in the app
When I press "Submit" in the app
And I press "Submit all and finish" in the app
Then I should find "Once you submit" in the app
When I press "Cancel" near "Once you submit" in the app
Then I should find "Summary of attempt" in the app
When I press "Submit all and finish" in the app
And I press "OK" near "Once you submit" in the app
Then I should find "Review" in the app

View File

@ -32,6 +32,7 @@ import { JitCompilerFactory } from '@angular/platform-browser-dynamic';
import { CoreCronDelegate } from '@services/cron'; import { CoreCronDelegate } from '@services/cron';
import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron'; import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron';
import { moodleTransitionAnimation } from '@classes/page-transition'; import { moodleTransitionAnimation } from '@classes/page-transition';
import { BehatTestingModule } from '@/testing/behat-testing.module';
// For translate loader. AoT requires an exported function for factories. // For translate loader. AoT requires an exported function for factories.
export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
@ -59,6 +60,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
AppRoutingModule, AppRoutingModule,
CoreModule, CoreModule,
AddonsModule, AddonsModule,
BehatTestingModule,
], ],
providers: [ providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },

View File

@ -208,10 +208,17 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy {
await content.componentOnReady(); await content.componentOnReady();
} }
parentPage = parentPage.parentElement.closest('.ion-page'); parentPage = parentPage.parentElement.closest('.ion-page, .ion-page-hidden, .ion-page-invisible');
// Check if the page has a header. If it doesn't, search the next parent page. // Check if the page has a header. If it doesn't, search the next parent page.
const header = parentPage?.querySelector<HTMLIonHeaderElement>(':scope > ion-header'); let header = parentPage?.querySelector<HTMLIonHeaderElement>(':scope > ion-header');
if (header && getComputedStyle(header).display !== 'none') {
return header;
}
// Find using content if any.
header = content?.parentElement?.querySelector<HTMLIonHeaderElement>(':scope > ion-header');
if (header && getComputedStyle(header).display !== 'none') { if (header && getComputedStyle(header).display !== 'none') {
return header; return header;

View File

@ -0,0 +1,143 @@
@core @core_course @app @javascript @lms_upto3.11
Feature: Test basic usage of one course in app
In order to participate in one course while using the mobile app
As a student
I need basic course functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
| student2 | Student2 | student2 | student2@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | option | section |
| choice | Choice course 1 | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 | 1 |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled | section |
| assign | C1 | assign1 | assignment | Test assignment description | 1 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | assessed | scale[modgrade_type] |
| forum | Test forum name | Test forum | C1 | forum | 0 | 5 | Point |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| chat | Test chat name | Test chat | C1 | chat | 0 | 2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| data | Web links | Useful links | C1 | data1 | 4 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| lti | Test external name | Test external | C1 | external | 0 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| feedback | Test feedback name | Test feedback | C1 | feedback | 0 | 3 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| glossary | Test glossary | glossary description | C1 | gloss1 | 5 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 2 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | TF1 | Text of the first question |
| Test questions | truefalse | TF2 | Text of the second question |
And quiz "Quiz 1" contains the following questions:
| question | page |
| TF1 | 1 |
| TF2 | 2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| survey | Test survey name | Test survey | C1 | survey | 0 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| wiki | Test wiki name | Test wiki | C1 | wiki | 0 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| lesson | Test lesson name | Test lesson | C1 | lesson | 0 | 3 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| scorm | Test scorm name | Test scorm | C1 | scorm | 0 | 2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| workshop | Test workshop name | Test workshop | C1 | workshop | 0 | 3 |
Scenario: Self enrol
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" 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 "teacher1"
And I press "Actions menu"
And I follow "More..."
And I follow "Users"
And I follow "Enrolment methods"
And I click on "Enable" "icon" in the "Self enrolment (Student)" "table_row"
And I close the browser tab opened by the app
Given I entered the app as "student2"
When I press "Site home" in the app
And I press "Available courses" in the app
And I press "Course 1" in the app
And I press "Enrol me" in the app
And I press "OK" in the app
And I wait loading to finish in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test external name" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
Scenario: Guest access
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" 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 "teacher1"
And I press "Actions menu"
And I follow "More..."
And I follow "Users"
And I follow "Enrolment methods"
And I click on "Enable" "icon" in the "Guest access" "table_row"
And I close the browser tab opened by the app
Given I entered the app as "student2"
When I press "Site home" in the app
And I press "Available courses" in the app
And I press "Course 1" in the app
Then I should find "Course summary" in the app
And I should find "Course" in the app
When I press "View course" "ion-button" in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app

View File

@ -0,0 +1,496 @@
@core @core_course @app @javascript
Feature: Test basic usage of one course in app
In order to participate in one course while using the mobile app
As a student
I need basic course functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
| student2 | Student2 | student2 | student2@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | option | section |
| choice | Choice course 1 | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 | 1 |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled | section |
| assign | C1 | assign1 | assignment | Test assignment description | 1 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | assessed | scale[modgrade_type] |
| forum | Test forum name | Test forum | C1 | forum | 0 | 5 | Point |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| chat | Test chat name | Test chat | C1 | chat | 0 | 2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| data | Web links | Useful links | C1 | data1 | 4 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| lti | Test external name | Test external | C1 | external | 0 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| feedback | Test feedback name | Test feedback | C1 | feedback | 0 | 3 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| glossary | Test glossary | glossary description | C1 | gloss1 | 5 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | section |
| quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 2 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | TF1 | Text of the first question |
| Test questions | truefalse | TF2 | Text of the second question |
And quiz "Quiz 1" contains the following questions:
| question | page |
| TF1 | 1 |
| TF2 | 2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| survey | Test survey name | Test survey | C1 | survey | 0 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| wiki | Test wiki name | Test wiki | C1 | wiki | 0 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| lesson | Test lesson name | Test lesson | C1 | lesson | 0 | 3 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| scorm | Test scorm name | Test scorm | C1 | scorm | 0 | 2 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode | section |
| workshop | Test workshop name | Test workshop | C1 | workshop | 0 | 3 |
Scenario: View course contents
When I entered the course "Course 1" as "student1" in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test external name" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
When I press "Choice course 1" in the app
Then the header should be "Choice course 1" in the app
When I press the back button in the app
And I press "assignment" in the app
Then the header should be "assignment" in the app
When I press the back button in the app
And I press "Test forum name" in the app
Then the header should be "Test forum name" in the app
When I press the back button in the app
And I press "Test chat name" in the app
Then the header should be "Test chat name" in the app
When I press the back button in the app
And I press "Web links" in the app
Then the header should be "Web links" in the app
When I press the back button in the app
And I press "Test external name" in the app
Then the header should be "Test external name" in the app
When I press the back button in the app
And I press "Test feedback name" in the app
And I press "OK" in the app
Then the header should be "Test feedback name" in the app
When I press the back button in the app
And I press "Test glossary" in the app
Then the header should be "Test glossary" in the app
When I press the back button in the app
And I press "Quiz 1" in the app
Then the header should be "Quiz 1" in the app
When I press the back button in the app
And I press "Test survey name" in the app
Then the header should be "Test survey name" in the app
When I press the back button in the app
And I press "Test wiki name" in the app
And I press "OK" in the app
Then the header should be "Test wiki name" in the app
When I press the back button in the app
And I press "Test lesson name" in the app
Then the header should be "Test lesson name" in the app
When I press the back button in the app
And I press "Test scorm name" in the app
Then the header should be "Test scorm name" in the app
When I press the back button in the app
And I press "Test workshop name" in the app
Then the header should be "Test workshop name" in the app
Scenario: View section contents
When I entered the course "Course 1" as "student1" in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test external name" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
When I press "Course index" in the app
And I press "General" in the app
Then I should find "Test forum name" in the app
And I should find "Test wiki name" in the app
But I should not find "Choice course 1" in the app
And I should not find "assignment" in the app
And I should not find "Test chat name" in the app
And I should not find "Web links" in the app
And I should not find "Test external name" in the app
And I should not find "Test feedback name" in the app
And I should not find "Test glossary" in the app
And I should not find "Quiz 1" in the app
And I should not find "Test survey name" in the app
And I should not find "Test lesson name" in the app
And I should not find "Test scorm name" in the app
And I should not find "Test workshop name" in the app
When I press "Test forum name" in the app
Then the header should be "Test forum name" in the app
When I press the back button in the app
And I press "Test wiki name" in the app
And I press "OK" in the app
Then the header should be "Test wiki name" in the app
When I press the back button in the app
And I press "Course index" in the app
And I press "Topic 1" in the app
Then I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test external name" in the app
And I should find "Test survey name" in the app
But I should not find "Test forum name" in the app
And I should not find "Test chat name" in the app
And I should not find "Web links" in the app
And I should not find "Test feedback name" in the app
And I should not find "Test glossary" in the app
And I should not find "Quiz 1" in the app
And I should not find "Test wiki name" in the app
And I should not find "Test lesson name" in the app
And I should not find "Test scorm name" in the app
And I should not find "Test workshop name" in the app
When I press "Choice course 1" in the app
Then the header should be "Choice course 1" in the app
When I press the back button in the app
And I press "assignment" in the app
Then the header should be "assignment" in the app
When I press the back button in the app
And I press "Test external name" in the app
Then the header should be "Test external name" in the app
When I press the back button in the app
And I press "Test survey name" in the app
Then the header should be "Test survey name" in the app
When I press the back button in the app
And I press "Course index" in the app
And I press "Topic 2" in the app
Then I should find "Quiz 1" in the app
And I should find "Test chat name" in the app
And I should find "Test scorm name" in the app
But I should not find "Choice course 1" in the app
And I should not find "assignment" in the app
And I should not find "Test forum name" in the app
And I should not find "Web links" in the app
And I should not find "Test external name" in the app
And I should not find "Test feedback name" in the app
And I should not find "Test glossary" in the app
And I should not find "Test survey name" in the app
And I should not find "Test wiki name" in the app
And I should not find "Test lesson name" in the app
And I should not find "Test workshop name" in the app
When I press "Test chat name" in the app
Then the header should be "Test chat name" in the app
When I press the back button in the app
And I press "Quiz 1" in the app
Then the header should be "Quiz 1" in the app
When I press the back button in the app
And I press "Test scorm name" in the app
Then the header should be "Test scorm name" in the app
When I press the back button in the app
And I press "Course index" in the app
And I press "Topic 3" in the app
Then I should find "Test feedback name" in the app
And I should find "Test lesson name" in the app
And I should find "Test workshop name" in the app
But I should not find "Choice course 1" in the app
And I should not find "assignment" in the app
And I should not find "Test forum name" in the app
And I should not find "Test chat name" in the app
And I should not find "Web links" in the app
And I should not find "Test external name" in the app
And I should not find "Test glossary" in the app
And I should not find "Quiz 1" in the app
And I should not find "Test survey name" in the app
And I should not find "Test wiki name" in the app
And I should not find "Test scorm name" in the app
When I press "Test feedback name" in the app
And I press "OK" in the app
Then the header should be "Test feedback name" in the app
When I press the back button in the app
And I press "Test lesson name" in the app
Then the header should be "Test lesson name" in the app
When I press the back button in the app
And I press "Test workshop name" in the app
Then the header should be "Test workshop name" in the app
When I press the back button in the app
And I press "Course index" in the app
And I press "Topic 4" in the app
Then I should find "Web links" in the app
But I should not find "Choice course 1" in the app
And I should not find "assignment" in the app
And I should not find "Test forum name" in the app
And I should not find "Test chat name" in the app
And I should not find "Test external name" in the app
And I should not find "Test feedback name" in the app
And I should not find "Test glossary" in the app
And I should not find "Quiz 1" in the app
And I should not find "Test survey name" in the app
And I should not find "Test wiki name" in the app
And I should not find "Test lesson name" in the app
And I should not find "Test scorm name" in the app
And I should not find "Test workshop name" in the app
When I press "Web links" in the app
Then the header should be "Web links" in the app
When I press the back button in the app
And I press "Course index" in the app
And I press "Topic 5" in the app
Then I should find "Test glossary" in the app
But I should not find "Choice course 1" in the app
And I should not find "assignment" in the app
And I should not find "Test forum name" in the app
And I should not find "Test chat name" in the app
And I should not find "Web links" in the app
And I should not find "Test external name" in the app
And I should not find "Test feedback name" in the app
And I should not find "Quiz 1" in the app
And I should not find "Test survey name" in the app
And I should not find "Test wiki name" in the app
And I should not find "Test lesson name" in the app
And I should not find "Test scorm name" in the app
And I should not find "Test workshop name" in the app
When I press "Test glossary" in the app
Then the header should be "Test glossary" in the app
Scenario: Navigation between sections using the bottom arrows
When I entered the course "Course 1" as "student1" in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test external name" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
When I press "Course index" in the app
And I press "General" in the app
Then I should find "General" in the app
And I should find "Next: Topic 1" in the app
But I should not find "Topic 2" in the app
And I should not find "Topic 3" in the app
And I should not find "Topic 4" in the app
And I should not find "Topic 5" in the app
And I should not find "Previous:" in the app
When I press "Next:" in the app
Then I should find "Topic 1" in the app
And I should find "Previous: General" in the app
And I should find "Next: Topic 2" in the app
But I should not find "Topic 3" in the app
And I should not find "Topic 4" in the app
And I should not find "Topic 5" in the app
When I press "Next:" in the app
Then I should find "Topic 2" in the app
And I should find "Previous: Topic 1" in the app
And I should find "Next: Topic 3" in the app
But I should not find "General" in the app
And I should not find "Topic 4" in the app
And I should not find "Topic 5" in the app
When I press "Next:" in the app
Then I should find "Topic 3" in the app
And I should find "Previous: Topic 2" in the app
And I should find "Next: Topic 4" in the app
But I should not find "General" in the app
And I should not find "Topic 1" in the app
And I should not find "Topic 5" in the app
When I press "Next:" in the app
Then I should find "Topic 4" in the app
And I should find "Previous: Topic 3" in the app
And I should find "Next: Topic 5" in the app
But I should not find "General" in the app
And I should not find "Topic 1" in the app
And I should not find "Topic 2" in the app
When I press "Next:" in the app
Then I should find "Topic 5" in the app
And I should find "Previous: Topic 4" in the app
But I should not find "General" in the app
And I should not find "Topic 1" in the app
And I should not find "Topic 2" in the app
And I should not find "Topic 3" in the app
And I should not find "Next:" in the app
When I press "Previous:" in the app
Then I should find "Topic 4" in the app
And I should find "Previous: Topic 3" in the app
And I should find "Next: Topic 5" in the app
But I should not find "General" in the app
And I should not find "Topic 1" in the app
And I should not find "Topic 2" in the app
@lms_from4.0
Scenario: Self enrol
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" 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 "teacher1"
And I click on "Participants" "link"
And I select "Enrolment methods" from the "jump" singleselect
And I click on "Enable" "icon" in the "Self enrolment (Student)" "table_row"
And I close the browser tab opened by the app
Given I entered the app as "student2"
When I press "Site home" in the app
And I press "Available courses" in the app
And I press "Course 1" in the app
And I press "Enrol me" in the app
And I press "OK" in the app
And I wait loading to finish in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test external name" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
@lms_from4.0
Scenario: Guest access
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" 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 "teacher1"
And I click on "Participants" "link"
And I select "Enrolment methods" from the "jump" singleselect
And I click on "Enable" "icon" in the "Guest access" "table_row"
And I close the browser tab opened by the app
Given I entered the app as "student2"
When I press "Site home" in the app
And I press "Available courses" in the app
And I press "Course 1" in the app
Then I should find "Course summary" in the app
And I should find "Course" in the app
When I press "View course" "ion-button" in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
Scenario: View blocks on drawer
Given the following "blocks" exist:
| blockname | contextlevel | reference | pagetypepattern | defaultregion | configdata |
| html | Course | C1 | course-view-* | site-pre | Tzo4OiJzdGRDbGFzcyI6Mjp7czo1OiJ0aXRsZSI7czoxNToiSFRNTCB0aXRsZSB0ZXN0IjtzOjQ6InRleHQiO3M6OToiYm9keSB0ZXN0Ijt9 |
| activity_modules | Course | C1 | course-view-* | site-pre | |
And I entered the course "Course 1" as "student1" in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test external name" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
Then I press "Open block drawer" in the app
And I should find "HTML title test" in the app
And I should find "body test" in the app
And I should find "Activities" in the app

View File

@ -0,0 +1,34 @@
@core @core_course @app @javascript @lms_upto3.10
Feature: Check course completion feature.
In order to track the progress of the course on mobile device
As a student
I need to be able to update the activity completion status.
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category | enablecompletion |
| Course 1 | C1 | 0 | 1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
Scenario: Activity completion, marking the checkbox manually
Given the following "activities" exist:
| activity | name | course | idnumber | completion | completionview |
| forum | First forum | C1 | forum1 | 1 | 0 |
| forum | Second forum | C1 | forum2 | 1 | 0 |
And I entered the course "Course 1" as "student1" in the app
# Set activities as completed.
Then I should find "0%" in the app
And I click on "ion-button[title=\"Not completed: First forum. Select to mark as complete.\"]" "css"
And I should find "50%" in the app
And I click on "ion-button[title=\"Not completed: Second forum. Select to mark as complete.\"]" "css"
And I should find "100%" in the app
# Set activities as not completed.
And I click on "ion-button[title=\"Completed: First forum. Select to mark as not complete.\"]" "css"
And I should find "50%" in the app
And I click on "ion-button[title=\"Completed: Second forum. Select to mark as not complete.\"]" "css"
And I should find "0%" in the app

View File

@ -0,0 +1,35 @@
@core @core_course @app @javascript
Feature: Check course completion feature.
In order to track the progress of the course on mobile device
As a student
I need to be able to update the activity completion status.
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| student1 | Student | 1 | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category | enablecompletion |
| Course 1 | C1 | 0 | 1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
@lms_from3.11
Scenario: Activity completion, marking the checkbox manually
Given the following "activities" exist:
| activity | name | course | idnumber | completion | completionview |
| forum | First forum | C1 | forum1 | 1 | 0 |
| forum | Second forum | C1 | forum2 | 1 | 0 |
And I entered the course "Course 1" as "student1" in the app
# Set activities as completed.
Then I should find "0%" in the app
And I press "Mark First forum as done" in the app
And I should find "50%" in the app
And I press "Mark Second forum as done" in the app
And I should find "100%" in the app
# Set activities as not completed.
And I press "First forum is marked as done. Press to undo." in the app
And I should find "50%" in the app
And I press "Second forum is marked as done. Press to undo." in the app
And I should find "0%" in the app

View File

@ -0,0 +1,118 @@
@core @core_course @app @javascript @lms_upto3.11
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: View courses (shortnames not displayed)
Given I entered the app as "student1"
When I should find "Course 1" in the app
But I should not find "Course 2" in the app
But I should not find "C1" in the app
But I should not find "C2" in the app
Given I entered the app as "student2"
When I should find "Course 1" in the app
And I should find "Course 2" in the app
But I should not find "C1" in the app
But I should not find "C2" in the app
Scenario: Filter courses
Given the following config values are set as admin:
| 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 |
Given I entered the app as "student2"
When 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
And I set the field "search text" to "fr" in the app
Then I should find "C3" in the app
And I should find "C4" in the app
And I should find "Frog 3" in the app
And I should find "Frog 4" in the app
But I should not find "C1" in the app
And I should not find "C2" in the app
And I should not find "C5" in the app
And I should not find "C6" in the app
And I should not find "Course 1" in the app
And I should not find "Course 2" in the app
And I should not find "Course 5" in the app
And I should not find "Toad 6" in the app
When I set the field "search text" to "" 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

@ -0,0 +1,123 @@
@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 |
@lms_from4.0
Scenario: View courses (shortnames not displayed)
Given I entered the app as "student1"
When I press "My courses" in the app
Then I should find "Course 1" in the app
But I should not find "Course 2" in the app
But I should not find "C1" in the app
But I should not find "C2" in the app
Given I entered the app as "student2"
When I press "My courses" in the app
Then I should find "Course 1" in the app
And I should find "Course 2" in the app
But I should not find "C1" in the app
But I should not find "C2" in the app
@lms_from4.0
Scenario: Filter courses
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 |
Given I entered the app as "student2"
When I press "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
And I set the field "search text" to "fr" in the app
Then I should find "C3" in the app
And I should find "C4" in the app
And I should find "Frog 3" in the app
And I should find "Frog 4" in the app
But I should not find "C1" in the app
And I should not find "C2" in the app
And I should not find "C5" in the app
And I should not find "C6" in the app
And I should not find "Course 1" in the app
And I should not find "Course 2" in the app
And I should not find "Course 5" in the app
And I should not find "Toad 6" in the app
When I set the field "search text" to "" 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

@ -0,0 +1,81 @@
@core @core_course @app @javascript @lms_upto3.10
Feature: Test basic usage of courses in app
In order to participate in the courses while using the mobile app
As a student
I need basic courses functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
| Course 2 | C2 | 0 |
| Course 3 | C3 | 0 |
| Course 4 | C4 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| teacher1 | C2 | editingteacher |
| teacher1 | C3 | editingteacher |
| teacher1 | C4 | editingteacher |
| student1 | C1 | student |
| student1 | C2 | student |
| student1 | C3 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | option |
| choice | Choice course 1 | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 2 | Test choice description | C2 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 3 | Test choice description | C3 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 4 | Test choice description | C4 | choice1 | Option 1, Option 2, Option 3 |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled |
| assign | C1 | assign1 | assignment | Test assignment description | 1 |
Scenario: Links to actions in Timeline work for teachers/students
# Configure assignment as teacher
Given I entered the assign activity "assignment" on course "Course 1" as "teacher1" in the app
When I press "Information" 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 "teacher1"
And I press "Actions menu"
And I follow "Edit settings"
And I press "Expand all"
And I click on "duedate[enabled]" "checkbox"
And I click on "gradingduedate[enabled]" "checkbox"
And I press "Save and return to course"
And I close the browser tab opened by the app
# Submit assignment as student
Given I entered the app as "student1"
When I press "Open block drawer" in the app
And I press "Add submission" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "No attempt" in the app
And I should find "Due date" in the app
When I press "Add submission" in the app
And I set the field "Online text submissions" to "test" in the app
And I press "Save" in the app
And I press "Submit assignment" in the app
And I press "OK" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "Submitted for grading" in the app
And I should find "Due date" in the app
# Grade assignment as teacher
Given I entered the app as "teacher1"
When I press "Open block drawer" in the app
And I press "Grade" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "Time remaining" in the app
When I press "Needs grading" in the app
Then I should find "Student student" in the app
And I should find "Not graded" in the app

View File

@ -0,0 +1,126 @@
@core @core_course @app @javascript @lms_upto3.11
Feature: Test basic usage of courses in app
In order to participate in the courses while using the mobile app
As a student
I need basic courses functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
| Course 2 | C2 | 0 |
| Course 3 | C3 | 0 |
| Course 4 | C4 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| teacher1 | C2 | editingteacher |
| teacher1 | C3 | editingteacher |
| teacher1 | C4 | editingteacher |
| student1 | C1 | student |
| student1 | C2 | student |
| student1 | C3 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | option |
| choice | Choice course 1 | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 2 | Test choice description | C2 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 3 | Test choice description | C3 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 4 | Test choice description | C4 | choice1 | Option 1, Option 2, Option 3 |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled |
| assign | C1 | assign1 | assignment | Test assignment description | 1 |
Scenario: "Dashboard" tab displayed
Given I entered the app as "student1"
When I should see "Dashboard"
And the header should be "Acceptance test site" 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 "Course 3" in the app
When I press "Site home" in the app
Then I should find "Dashboard" in the app
And the header should be "Acceptance test site" in the app
When I press "Dashboard" in the app
Then I should find "Course 1" in the app
And I should find "Course 2" in the app
And I should find "Course 3" in the app
Scenario: See my courses
Given I entered the app as "student1"
When the header should be "Acceptance test site" 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 "Course 3" in the app
When I press "Course 1" in the app
Then I should find "Choice course 1" in the app
And the header should be "Course 1" in the app
When I press "Choice course 1" in the app
Then I should find "Test choice description" in the app
And the header should be "Choice course 1" in the app
When I press the back button in the app
And I press the back button in the app
And I press "Course 2" in the app
Then I should find "Choice course 2" in the app
And the header should be "Course 2" in the app
When I press the back button in the app
And I press "Course 3" in the app
Then I should find "Choice course 3" in the app
And the header should be "Course 3" in the app
@lms_from3.11
Scenario: Links to actions in Timeline work for teachers/students
# Configure assignment as teacher
Given I entered the course "Course 1" as "teacher1" in the app
When I press "assignment" in the app
And I press "Information" 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 "teacher1"
And I press "Actions menu"
And I follow "Edit settings"
And I press "Expand all"
And I click on "duedate[enabled]" "checkbox"
And I click on "gradingduedate[enabled]" "checkbox"
And I press "Save and return to course"
And I close the browser tab opened by the app
# Submit assignment as student
Given I entered the app as "student1"
When I press "Open block drawer" in the app
And I press "Add submission" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "No attempt" in the app
And I should find "Due:" in the app
When I press "Add submission" in the app
And I set the field "Online text submissions" to "test" in the app
And I press "Save" in the app
And I press "Submit assignment" in the app
And I press "OK" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "Submitted for grading" in the app
And I should find "Due:" in the app
# Grade assignment as teacher
Given I entered the app as "teacher1"
When I press "Open block drawer" in the app
And I press "Grade" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "Time remaining" in the app
When I press "Needs grading" in the app
Then I should find "Student student" in the app
And I should find "Not graded" in the app

View File

@ -0,0 +1,143 @@
@core @core_course @app @javascript
Feature: Test basic usage of courses in app
In order to participate in the courses while using the mobile app
As a student
I need basic courses functionality to work
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
| Course 2 | C2 | 0 |
| Course 3 | C3 | 0 |
| Course 4 | C4 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| teacher1 | C2 | editingteacher |
| teacher1 | C3 | editingteacher |
| teacher1 | C4 | editingteacher |
| student1 | C1 | student |
| student1 | C2 | student |
| student1 | C3 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber | option |
| choice | Choice course 1 | Test choice description | C1 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 2 | Test choice description | C2 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 3 | Test choice description | C3 | choice1 | Option 1, Option 2, Option 3 |
| choice | Choice course 4 | Test choice description | C4 | choice1 | Option 1, Option 2, Option 3 |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled |
| assign | C1 | assign1 | assignment | Test assignment description | 1 |
@lms_from4.0
Scenario: "Dashboard" tab displayed
Given I entered the app as "student1"
When I should see "Dashboard"
And the header should be "Acceptance test site" in the app
And I should see "Timeline"
And I press "Site home" in the app
Then I should find "Dashboard" in the app
And the header should be "Acceptance test site" in the app
When I press "My courses" in the app
Then I should find "Course 1" in the app
And I should find "Course 2" in the app
And I should find "Course 3" in the app
@lms_from4.0
Scenario: See my courses
Given I entered the app as "student1"
When the header should be "Acceptance test site" in the app
And I press "My courses" 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 "Course 3" in the app
When I press "Course 1" in the app
Then I should find "Choice course 1" in the app
And the header should be "Course 1" in the app
When I press "Choice course 1" in the app
Then I should find "Test choice description" in the app
And the header should be "Choice course 1" in the app
When I press the back button in the app
And I press the back button in the app
And I press "Course 2" in the app
Then I should find "Choice course 2" in the app
And the header should be "Course 2" in the app
When I press the back button in the app
And I press "Course 3" in the app
Then I should find "Choice course 3" in the app
And the header should be "Course 3" in the app
Scenario: Search for a course
Given I entered the app as "student1"
When I press "Search courses" in the app
And I set the field "Search" to "Course 4" in the app
And I press "Search" "button" in the app
Then I should find "Course 4" in the app
And the header should be "Available courses" in the app
When I press "Course 4" in the app
Then I should find "Course 4" in the app
And I should find "Course summary" in the app
When I press the back button in the app
And I set the field "Search" to "Course" in the app
And I press "Search" "button" in the app
Then I should find "Course 1" in the app
And I should find "Course 2" in the app
And I should find "Course 3" in the app
And I should find "Course 4" in the app
@lms_from4.0
# TODO remove LMS UI steps in app tests
Scenario: Links to actions in Timeline work for teachers/students
# Configure assignment as teacher
Given I entered the assign activity "assignment" on course "Course 1" as "teacher1" in the app
When I press "Information" 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 "teacher1"
And I navigate to "Settings" in current page administration
And I click on "Expand all" "link"
And I click on "duedate[enabled]" "checkbox"
And I click on "gradingduedate[enabled]" "checkbox"
And I press "Save and return to course"
And I close the browser tab opened by the app
# Submit assignment as student
Given I entered the app as "student1"
When I press "Add submission" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "No attempt" in the app
And I should find "Due:" in the app
When I press "Add submission" in the app
And I set the field "Online text submissions" to "test" in the app
And I press "Save" in the app
And I press "Submit assignment" in the app
And I press "OK" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "Submitted for grading" in the app
And I should find "Due:" in the app
# Grade assignment as teacher
Given I entered the app as "teacher1"
When I press "Grade" in the app
Then the header should be "assignment" in the app
And I should find "Test assignment description" in the app
And I should find "Time remaining" in the app
When I press "Needs grading" in the app
Then I should find "Student student" in the app
And I should find "Not graded" in the app

View File

@ -0,0 +1,89 @@
@auth @core_auth @app @javascript
Feature: Test basic usage of login in app
I need basic login functionality to work
Background:
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username | firstname | lastname |
| student1 | david | student |
| student2 | pau | student2 |
| teacher1 | juan | teacher |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student2 | C1 | student |
| teacher1 | C1 | editingteacher |
Scenario: Skip on boarding
When I launch the app runtime
Then I should find "Welcome to the Moodle App!" in the app
When I press "Skip" in the app
Then I should not find "Skip" in the app
And I should find "Connect to Moodle" in the app
Scenario: Add a new account in the app & Site name in displayed when adding a new account
When I enter the app
And I press the back button in the app
And I set the field "Your site" to "$WWWROOT" in the app
And I press "Connect to your site" in the app
Then I should find "Acceptance test site" in the app
When I set the field "Username" to "student1" in the app
And I set the field "Password" to "student1" in the app
And I press "Log in" near "Forgotten your username or password?" in the app
Then I should find "Acceptance test site" in the app
But I should not find "Log in" in the app
Scenario: Add a non existing account
When I enter the app
And I log in as "student1"
And I press the user menu button in the app
And I press "Log out" in the app
And I wait the app to restart
And I press "Add" in the app
And I set the field "Your site" to "Wrong Site Address" in the app
And I press enter in the app
Then I should find "Cannot connect" in the app
And I should find "Wrong Site Address" in the app
Scenario: Add a non existing account from accounts switcher
When I enter the app
And I log in as "student1"
And I press the user menu button in the app
And I press "Switch account" in the app
And I press "Add" in the app
And I wait the app to restart
And I set the field "Your site" to "Wrong Site Address" in the app
And I press enter in the app
Then I should find "Cannot connect" in the app
And I should find "Wrong Site Address" in the app
Scenario: Delete an account
Given I entered the app as "student1"
When I press the user menu button in the app
And I press "Log out" in the app
And I wait the app to restart
Then I should find "Acceptance test site" in the app
And I press "Edit accounts list" in the app
And I press "Remove account" near "Acceptance test site" in the app
And I press "Delete" near "Are you sure you want to remove the account on Acceptance test site?" in the app
Then I should find "Connect to Moodle" in the app
But I should not find "Acceptance test site" in the app
Scenario: Require minium version of the app for a site
# Log in with a previous required version
Given the following config values are set as admin:
| minimumversion | 3.8.1 | tool_mobile |
When I enter the app
Then I should not find "App update required" in the app
# Log in with a future required version
Given the following config values are set as admin:
| minimumversion | 11.0.0 | tool_mobile |
When I enter the app
Then I should find "App update required" in the app

View File

@ -0,0 +1,23 @@
@app @javascript @lms_upto3.11
Feature: Main Menu opens the right page
Background:
Given the following "users" exist:
| username |
| student |
Scenario: Opens Site Home when defaulthomepage is set to Site
Given the following config values are set as admin:
| defaulthomepage | 0 |
Given I entered the app as "student"
When "Site home" should be selected in the app
And I should find "Available courses" in the app
And "Site home" "text" should appear before "Dashboard" "text" in the ".core-tabs-bar" "css_element"
Scenario: Opens Dashboard when defaulthomepage is set to Dashboard
Given the following config values are set as admin:
| defaulthomepage | 1 |
Given I entered the app as "student"
When "Dashboard" should be selected in the app
And I should find "Course overview" in the app
And "Dashboard" "text" should appear before "Site home" "text" in the ".core-tabs-bar" "css_element"

View File

@ -0,0 +1,42 @@
@app @javascript
Feature: Main Menu opens the right page
Background:
Given the following "users" exist:
| username |
| student |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student | C1 | student |
@lms_from4.0
Scenario: Opens Site Home when defaulthomepage is set to Site
Given the following config values are set as admin:
| defaulthomepage | 0 |
Given I entered the app as "student"
When "Site home" should be selected in the app
And I should find "Available courses" in the app
And "Site home" "text" should appear before "Dashboard" "text" in the ".core-tabs-bar" "css_element"
And "Home" "text" should appear before "My courses" "text" in the ".mainmenu-tabs" "css_element"
@lms_from4.0
Scenario: Opens Dashboard when defaulthomepage is set to Dashboard
Given the following config values are set as admin:
| defaulthomepage | 1 |
Given I entered the app as "student"
When "Dashboard" should be selected in the app
And I should find "Timeline" in the app
And "Dashboard" "text" should appear before "Site home" "text" in the ".core-tabs-bar" "css_element"
And "Home" "text" should appear before "My courses" "text" in the ".mainmenu-tabs" "css_element"
@lms_from4.0
Scenario: Opens My Courses when defaulthomepage is set to My Courses
Given the following config values are set as admin:
| defaulthomepage | 3 |
Given I entered the app as "student"
When "My courses" near "Home" should be selected in the app
And I should find "Course 1" in the app
And "My courses" "text" should appear before "Home" "text" in the ".mainmenu-tabs" "css_element"

View File

@ -41,7 +41,7 @@ export class CoreSettingsGeneralPage {
languages: { code: string; name: string }[] = []; languages: { code: string; name: string }[] = [];
selectedLanguage = ''; selectedLanguage = '';
zoomLevels: { value: CoreZoomLevel; style: number; selected: boolean }[] = []; zoomLevels: { value: CoreZoomLevel; style: number; selected: boolean }[] = [];
selectedZoomLevel = CoreZoomLevel.NORMAL; selectedZoomLevel = CoreZoomLevel.NONE;
richTextEditor = true; richTextEditor = true;
debugDisplay = false; debugDisplay = false;
analyticsSupported = false; analyticsSupported = false;

View File

@ -50,8 +50,8 @@ export const enum CoreColorScheme {
* Constants to define zoom levels. * Constants to define zoom levels.
*/ */
export const enum CoreZoomLevel { export const enum CoreZoomLevel {
NORMAL = 'normal', NONE = 'none',
LOW = 'low', MEDIUM = 'medium',
HIGH = 'high', HIGH = 'high',
} }
@ -303,13 +303,13 @@ export class CoreSettingsHelperProvider {
} }
// Reset the value to solve edge cases. // Reset the value to solve edge cases.
CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NORMAL); CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NONE);
if (fontSize < 100) { if (fontSize < 100) {
if (fontSize > 90) { if (fontSize > 90) {
CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.HIGH); CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.HIGH);
} else if (fontSize > 70) { } else if (fontSize > 70) {
CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.LOW); CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.MEDIUM);
} }
} }
@ -326,7 +326,7 @@ export class CoreSettingsHelperProvider {
* @return The saved zoom Level option. * @return The saved zoom Level option.
*/ */
async getZoomLevel(): Promise<CoreZoomLevel> { async getZoomLevel(): Promise<CoreZoomLevel> {
return CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NORMAL); return CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreConstants.CONFIG.defaultZoomLevel);
} }
/** /**

View File

@ -0,0 +1,80 @@
@app @javascript
Feature: It navigates properly within settings.
Background:
Given the following "users" exist:
| username |
| student1 |
Scenario: Mobile navigation on settings
Given I entered the app as "student1"
# Settings
When I press "More" in the app
And I press "App settings" in the app
Then I should find "General" in the app
And I should find "Space usage" in the app
And I should find "Synchronisation" in the app
And I should find "About" in the app
# Settings details
When I press "General" in the app
Then I should find "Language" in the app
And I should find "Text size" in the app
When I press the back button in the app
And I press "About" in the app
Then I should find "Moodle Mobile" in the app
And I should find "Privacy policy" in the app
# Preferences
When I press the back button in the app
And I press the back button in the app
And I press the user menu button in the app
And I press "Preferences" in the app
Then I should find "Messages" in the app
And I should find "Notifications" in the app
And I should find "Manage downloads" in the app
# Preferences details
When I press "Messages" in the app
Then I should find "Accept messages from" in the app
And I should find "Notification preferences" in the app
When I press the back button in the app
And I press "Manage downloads" in the app
Then I should find "Total space used" in the app
Scenario: Tablet navigation on settings
Given I entered the app as "student1"
And I change viewport size to "1200x640"
# Settings
When I press "More" in the app
And I press "App settings" in the app
Then I should find "General" in the app
And I should find "Space usage" in the app
And I should find "Synchronisation" in the app
And I should find "About" in the app
And "General" should be selected in the app
And I should find "Language" in the app
And I should find "Text size" in the app
When I press "About" in the app
Then "About" should be selected in the app
And I should find "Moodle Mobile" in the app
And I should find "Privacy policy" in the app
# Preferences
When I press the user menu button in the app
And I press "Preferences" in the app
Then I should find "Messages" in the app
And I should find "Notifications" in the app
And I should find "Manage downloads" in the app
And "Messages" should be selected in the app
And I should find "Accept messages from" in the app
And I should find "Notification preferences" in the app
When I press "Manage downloads" in the app
Then "Manage downloads" should be selected in the app
And I should find "Total space used" in the app

View File

@ -0,0 +1,15 @@
@app @javascript
Feature: Plugins work properly.
Background:
Given the following "users" exist:
| username |
| studentusername |
Scenario: See more menu button
Given I entered the app as "studentusername"
When I press the more menu button in the app
Then I should find "Moodle App Behat (auto-generated)" in the app
When I press "Moodle App Behat (auto-generated)" in the app
Then I should find "studentusername" in the app

View File

@ -0,0 +1,47 @@
@app @javascript
Feature: User Tours work properly.
Background:
Given the following "users" exist:
| username | firstname | lastname |
| student1 | Student | First |
| student2 | Student | Second |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student2 | C1 | student |
And the app has the following config:
| disableUserTours | false |
Scenario: Acknowledge User Tours
Given I entered the app as "student1"
When I should find "Explore your personal area" in the app
But I should not find "Expand to explore" in the app
When I press "Got it" in the app
Then I should find "Expand to explore" in the app
But I should not find "Explore your personal area" in the app
When I press "Got it" in the app
Then I should not find "Expand to explore" in the app
And I should not find "Explore your personal area" in the app
Given I entered the course "Course 1" in the app
Then I should find "Find your way around" in the app
When I press "Got it" in the app
Then I should not find "Find your way around" in the app
When I press "Participants" in the app
And I press "Student First" in the app
Then I should find "Swipe left and right to navigate around" in the app
When I press "Got it" in the app
Then I should not find "Swipe left and right to navigate around" in the app
When I press the back button in the app
And I press "Student First" in the app
Then I should not find "Swipe left and right to navigate around" in the app

View File

@ -23,6 +23,7 @@ import { CoreLoginHelper } from '@features/login/services/login-helper';
import { CoreSites } from './sites'; import { CoreSites } from './sites';
import { CoreUtils, PromiseDefer } from './utils/utils'; import { CoreUtils, PromiseDefer } from './utils/utils';
import { CoreApp } from './app'; import { CoreApp } from './app';
import { CoreZoomLevel } from '@features/settings/services/settings-helper';
const VERSION_APPLIED = 'version_applied'; const VERSION_APPLIED = 'version_applied';
@ -71,6 +72,10 @@ export class CoreUpdateManagerProvider {
promises.push(CoreH5P.h5pPlayer.deleteAllContentIndexes()); promises.push(CoreH5P.h5pPlayer.deleteAllContentIndexes());
} }
if (versionCode >= 41000 && versionApplied < 41000 && versionApplied > 0) {
promises.push(this.upgradeFontSizeNames());
}
try { try {
await Promise.all(promises); await Promise.all(promises);
@ -121,6 +126,19 @@ export class CoreUpdateManagerProvider {
}); });
} }
protected async upgradeFontSizeNames(): Promise<void> {
const storedFontSizeName = await CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL);
switch (storedFontSizeName) {
case 'low':
await CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NONE);
break;
case 'normal':
await CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.MEDIUM);
break;
}
}
} }
export const CoreUpdateManager = makeSingleton(CoreUpdateManagerProvider); export const CoreUpdateManager = makeSingleton(CoreUpdateManagerProvider);

View File

@ -108,7 +108,7 @@ export class CoreCustomURLSchemesProvider {
// Some platforms like Windows add a slash at the end. Remove it. // Some platforms like Windows add a slash at the end. Remove it.
// Some sites add a # at the end of the URL. If it's there, remove it. // Some sites add a # at the end of the URL. If it's there, remove it.
url = url.replace(/\/?#?\/?$/, ''); url = url.replace(/\/?(#.*)?\/?$/, '');
const modal = await CoreDomUtils.showModalLoading(); const modal = await CoreDomUtils.showModalLoading();
let data: CoreCustomURLSchemesParams; let data: CoreCustomURLSchemesParams;

View File

@ -1792,12 +1792,12 @@ export class CoreDomUtilsProvider {
const { waitForDismissCompleted, ...popoverOptions } = options; const { waitForDismissCompleted, ...popoverOptions } = options;
const popover = await PopoverController.create(popoverOptions); const popover = await PopoverController.create(popoverOptions);
const zoomLevel = await CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NORMAL); const zoomLevel = await CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreConstants.CONFIG.defaultZoomLevel);
await popover.present(); await popover.present();
// Fix popover position if zoom is applied. // Fix popover position if zoom is applied.
if (zoomLevel !== CoreZoomLevel.NORMAL) { if (zoomLevel !== CoreZoomLevel.NONE) {
switch (getMode()) { switch (getMode()) {
case 'ios': case 'ios':
fixIOSPopoverPosition(popover, options.event); fixIOSPopoverPosition(popover, options.event);

View File

@ -0,0 +1,21 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
/**
* Stub used in production to avoid including testing code in production bundles.
*/
@NgModule({})
export class BehatTestingModule {}

View File

@ -0,0 +1,34 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreAppProvider } from '@services/app';
import { TestsBehatBlockingService } from './services/behat-blocking';
import { BehatTestsWindow, TestsBehatRuntime } from './services/behat-runtime';
function initializeBehatTestsWindow(window: BehatTestsWindow) {
// Make functions publicly available for Behat to call.
window.behatInit = TestsBehatRuntime.init;
}
@NgModule({
providers:
CoreAppProvider.isAutomated()
? [
{ provide: APP_INITIALIZER, multi: true, useValue: () => initializeBehatTestsWindow(window) },
TestsBehatBlockingService,
]
: [],
})
export class BehatTestingModule {}

View File

@ -0,0 +1,242 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, NgZone } from '@singletons';
import { BehatTestsWindow, TestsBehatRuntime } from './behat-runtime';
/**
* Behat block JS manager.
*/
@Injectable({ providedIn: 'root' })
export class TestsBehatBlockingService {
protected waitingBlocked = false;
protected recentMutation = false;
protected lastMutation = 0;
protected initialized = false;
protected keyIndex = 0;
/**
* Listen to mutations and override XML Requests.
*/
init(): void {
if (this.initialized) {
return;
}
this.initialized = true;
this.listenToMutations();
this.xmlRequestOverride();
const win = window as BehatTestsWindow;
// Set up the M object - only pending_js is implemented.
win.M = win.M ?? {};
win.M.util = win.M.util ?? {};
win.M.util.pending_js = win.M.util.pending_js ?? [];
TestsBehatRuntime.log('Initialized!');
}
/**
* Get pending list on window M object.
*/
protected get pendingList(): string[] {
const win = window as BehatTestsWindow;
return win.M?.util?.pending_js || [];
}
/**
* Set pending list on window M object.
*/
protected set pendingList(values: string[]) {
const win = window as BehatTestsWindow;
if (!win.M?.util?.pending_js) {
return;
}
win.M.util.pending_js = values;
}
/**
* Adds a pending key to the array.
*
* @param key Key to add. It will be generated if none.
* @return Key name.
*/
block(key = ''): string {
// Add a special DELAY entry whenever another entry is added.
if (this.pendingList.length === 0) {
this.pendingList.push('DELAY');
}
if (!key) {
key = 'generated-' + this.keyIndex;
this.keyIndex++;
}
this.pendingList.push(key);
TestsBehatRuntime.log('PENDING+: ' + this.pendingList);
return key;
}
/**
* 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 key Key to remove
*/
async unblock(key: string): Promise<void> {
// Remove the key immediately.
this.pendingList = this.pendingList.filter((x) => x !== key);
TestsBehatRuntime.log('PENDING-: ' + this.pendingList);
// If the only thing left is DELAY, then remove that as well, later...
if (this.pendingList.length === 1) {
if (!document.hidden) {
// When tab is not active, ticks should be slower and may do Behat to fail.
// From Timers API:
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers
// "This API does not guarantee that timers will run exactly on schedule.
// Delays due to CPU load, other tasks, etc, are to be expected."
await CoreUtils.nextTicks(10);
}
// Check there isn't a spinner...
await this.checkUIBlocked();
// Only remove it if the pending array is STILL empty after all that.
if (this.pendingList.length === 1) {
this.pendingList = [];
TestsBehatRuntime.log('PENDING-: ' + this.pendingList);
}
}
}
/**
* Adds a pending key to the array, but removes it after some ticks.
*/
async delay(): Promise<void> {
const key = this.block('forced-delay');
this.unblock(key);
}
/**
* 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.
*/
protected listenToMutations(): void {
// Set listener using the mutation callback.
const observer = new MutationObserver(() => {
this.lastMutation = Date.now();
if (!this.recentMutation) {
this.recentMutation = true;
this.block('dom-mutation');
setTimeout(() => {
this.pollRecentMutation();
}, 500);
}
// Also update the spinner presence if needed.
this.checkUIBlocked();
});
observer.observe(document, { attributes: true, childList: true, subtree: true });
}
/**
* 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.
*/
protected pollRecentMutation(): void {
if (Date.now() - this.lastMutation > 500) {
this.recentMutation = false;
this.unblock('dom-mutation');
return;
}
setTimeout(() => {
this.pollRecentMutation();
}, 100);
}
/**
* Checks if a loading spinner is present and visible; if so, adds it to the pending array
* (and if not, removes it).
*/
protected async checkUIBlocked(): Promise<void> {
await CoreUtils.nextTick();
const blocked = document.querySelector<HTMLElement>('div.core-loading-container, ion-loading, .click-block-active');
if (blocked?.offsetParent) {
if (!this.waitingBlocked) {
this.block('blocked');
this.waitingBlocked = true;
}
} else {
if (this.waitingBlocked) {
this.unblock('blocked');
this.waitingBlocked = false;
}
}
}
/**
* Override XMLHttpRequest to mark things pending while there is a request waiting.
*/
protected xmlRequestOverride(): void {
const realOpen = XMLHttpRequest.prototype.open;
let requestIndex = 0;
XMLHttpRequest.prototype.open = function(...args) {
NgZone.run(() => {
const index = requestIndex++;
const key = 'httprequest-' + index;
try {
// Add to the list of pending requests.
TestsBehatBlocking.block(key);
// Detect when it finishes and remove it from the list.
this.addEventListener('loadend', () => {
TestsBehatBlocking.unblock(key);
});
return realOpen.apply(this, args);
} catch (error) {
TestsBehatBlocking.unblock(key);
throw error;
}
});
};
}
}
export const TestsBehatBlocking = makeSingleton(TestsBehatBlockingService);

View File

@ -0,0 +1,540 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreUtils } from '@services/utils/utils';
import { NgZone } from '@singletons';
import { TestsBehatBlocking } from './behat-blocking';
import { TestBehatElementLocator } from './behat-runtime';
/**
* Behat Dom Utils helper functions.
*/
export class TestsBehatDomUtils {
/**
* Check if an element is visible.
*
* @param element Element.
* @param container Container.
* @return Whether the element is visible or not.
*/
static isElementVisible(element: HTMLElement, container: HTMLElement): boolean {
if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') {
return false;
}
const parentElement = this.getParentElement(element);
if (parentElement === container) {
return true;
}
if (!parentElement) {
return false;
}
return this.isElementVisible(parentElement, container);
}
/**
* Check if an element is selected.
*
* @param element Element.
* @param container Container.
* @return Whether the element is selected or not.
*/
static isElementSelected(element: HTMLElement, container: HTMLElement): boolean {
const ariaCurrent = element.getAttribute('aria-current');
if (
(ariaCurrent && ariaCurrent !== 'false') ||
(element.getAttribute('aria-selected') === 'true') ||
(element.getAttribute('aria-checked') === 'true')
) {
return true;
}
const parentElement = this.getParentElement(element);
if (!parentElement || parentElement === container) {
return false;
}
return this.isElementSelected(parentElement, container);
};
/**
* Finds elements within a given container with exact info.
*
* @param container Parent element to search the element within
* @param text Text to look for
* @return Elements containing the given text with exact boolean.
*/
protected static findElementsBasedOnTextWithinWithExact(container: HTMLElement, text: string): ElementsWithExact[] {
const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"]`;
const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector))
.filter((element => this.isElementVisible(element, container)))
.map((element) => {
const exact = this.checkElementLabel(element, text);
return { element, exact };
});
const treeWalker = document.createTreeWalker(
container,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise
{
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: Node | null = null;
// eslint-disable-next-line no-cond-assign
while (currentNode = treeWalker.nextNode()) {
if (currentNode instanceof Text) {
if (currentNode.textContent?.includes(text) && currentNode.parentElement) {
elements.push({
element: currentNode.parentElement,
exact: currentNode.textContent.trim() === text,
});
}
continue;
}
if (currentNode instanceof HTMLElement) {
const labelledBy = currentNode.getAttribute('aria-labelledby');
const labelElement = labelledBy && container.querySelector<HTMLElement>(`#${labelledBy}`);
if (labelElement && labelElement.innerText && labelElement.innerText.includes(text)) {
elements.push({
element: currentNode,
exact: labelElement.innerText.trim() == text,
});
continue;
}
}
if (currentNode instanceof Element && currentNode.shadowRoot) {
for (const childNode of Array.from(currentNode.shadowRoot.childNodes)) {
if (!(childNode instanceof HTMLElement) || (
childNode instanceof HTMLStyleElement ||
childNode instanceof HTMLLinkElement ||
childNode instanceof HTMLScriptElement)) {
continue;
}
if (childNode.matches(attributesSelector)) {
elements.push({
element: childNode,
exact: this.checkElementLabel(childNode, text),
});
continue;
}
elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text));
}
}
}
return elements;
};
/**
* Checks an element has exactly the same label (title, alt or aria-label).
*
* @param element Element to check.
* @param text Text to check.
* @return If text matches any of the label attributes.
*/
protected static checkElementLabel(element: HTMLElement, text: string): boolean {
return element.title === text ||
element.getAttribute('alt') === text ||
element.getAttribute('aria-label') === text;
}
/**
* Finds elements within a given container.
*
* @param container Parent element to search the element within.
* @param text Text to look for.
* @return Elements containing the given text.
*/
protected static findElementsBasedOnTextWithin(container: HTMLElement, text: string): HTMLElement[] {
const elements = this.findElementsBasedOnTextWithinWithExact(container, text);
// Give more relevance to exact matches.
elements.sort((a, b) => Number(b.exact) - Number(a.exact));
return elements.map(element => element.element);
};
/**
* Given a list of elements, get the top ancestors among all of them.
*
* This will remote duplicates and drop any elements nested within each other.
*
* @param elements Elements list.
* @return Top ancestors.
*/
protected static getTopAncestors(elements: HTMLElement[]): HTMLElement[] {
const uniqueElements = new Set(elements);
for (const element of uniqueElements) {
for (const otherElement of uniqueElements) {
if (otherElement === element) {
continue;
}
if (element.contains(otherElement)) {
uniqueElements.delete(otherElement);
}
}
}
return Array.from(uniqueElements);
};
/**
* Get parent element, including Shadow DOM parents.
*
* @param element Element.
* @return Parent element.
*/
protected static getParentElement(element: HTMLElement): HTMLElement | null {
return element.parentElement ||
(element.getRootNode() && (element.getRootNode() as ShadowRoot).host as HTMLElement) ||
null;
}
/**
* Get closest element matching a selector, without traversing up a given container.
*
* @param element Element.
* @param selector Selector.
* @param container Topmost container to search within.
* @return Closest matching element.
*/
protected static getClosestMatching(element: HTMLElement, selector: string, container: HTMLElement | null): HTMLElement | null {
if (element.matches(selector)) {
return element;
}
if (element === container || !element.parentElement) {
return null;
}
return this.getClosestMatching(element.parentElement, selector, container);
};
/**
* Function to find top container element.
*
* @param containerName Whether to search inside the a container name.
* @return Found top container element.
*/
protected static getCurrentTopContainerElement(containerName: string): HTMLElement | null {
let topContainer: HTMLElement | null = null;
let containers: HTMLElement[] = [];
const nonImplementedSelectors =
'ion-alert, ion-popover, ion-action-sheet, ion-modal, core-user-tours-user-tour.is-active, page-core-mainmenu, ion-app';
switch (containerName) {
case 'html':
containers = Array.from(document.querySelectorAll<HTMLElement>('html'));
break;
case 'toast':
containers = Array.from(document.querySelectorAll('ion-app ion-toast.hydrated'));
containers = containers.map(container => container?.shadowRoot?.querySelector('.toast-container') || container);
break;
case 'alert':
containers = Array.from(document.querySelectorAll('ion-app ion-alert.hydrated'));
break;
case 'action-sheet':
containers = Array.from(document.querySelectorAll('ion-app ion-action-sheet.hydrated'));
break;
case 'modal':
containers = Array.from(document.querySelectorAll('ion-app ion-modal.hydrated'));
break;
case 'popover':
containers = Array.from(document.querySelectorAll('ion-app ion-popover.hydrated'));
break;
case 'user-tour':
containers = Array.from(document.querySelectorAll('core-user-tours-user-tour.is-active'));
break;
default:
// Other containerName or not implemented.
containers = Array.from(document.querySelectorAll<HTMLElement>(nonImplementedSelectors));
}
if (containers.length > 0) {
// Get the one with more zIndex.
topContainer =
containers.reduce((a, b) => getComputedStyle(a).zIndex > getComputedStyle(b).zIndex ? a : b, containers[0]);
}
if (!topContainer) {
return null;
}
if (containerName == 'page' || containerName == 'split-view content') {
// Find non hidden pages inside the container.
let pageContainers = Array.from(topContainer.querySelectorAll<HTMLElement>('.ion-page:not(.ion-page-hidden)'));
pageContainers = pageContainers.filter((page) => !page.closest('.ion-page.ion-page-hidden'));
if (pageContainers.length > 0) {
// Get the more general one to avoid failing.
topContainer = pageContainers[0];
}
if (containerName == 'split-view content') {
topContainer = topContainer.querySelector<HTMLElement>('core-split-view ion-router-outlet');
}
}
return topContainer;
};
/**
* Function to find element based on their text or Aria label.
*
* @param locator Element locator.
* @param containerName Whether to search only inside a specific container.
* @return First found element.
*/
static findElementBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement {
return this.findElementsBasedOnText(locator, containerName)[0];
}
/**
* Function to find elements based on their text or Aria label.
*
* @param locator Element locator.
* @param containerName Whether to search only inside a specific container.
* @return Found elements
*/
protected static findElementsBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement[] {
let topContainer = this.getCurrentTopContainerElement(containerName);
let container = topContainer;
if (locator.within) {
const withinElements = this.findElementsBasedOnText(locator.within);
if (withinElements.length === 0) {
throw new Error('There was no match for within text');
} else if (withinElements.length > 1) {
const withinElementsAncestors = this.getTopAncestors(withinElements);
if (withinElementsAncestors.length > 1) {
throw new Error('Too many matches for within text');
}
topContainer = container = withinElementsAncestors[0];
} else {
topContainer = container = withinElements[0];
}
}
if (topContainer && locator.near) {
const nearElements = this.findElementsBasedOnText(locator.near);
if (nearElements.length === 0) {
throw new Error('There was no match for near text');
} else if (nearElements.length > 1) {
const nearElementsAncestors = this.getTopAncestors(nearElements);
if (nearElementsAncestors.length > 1) {
throw new Error('Too many matches for near text');
}
container = this.getParentElement(nearElementsAncestors[0]);
} else {
container = this.getParentElement(nearElements[0]);
}
}
do {
if (!container) {
break;
}
const elements = this.findElementsBasedOnTextWithin(container, locator.text);
let filteredElements: HTMLElement[] = elements;
if (locator.selector) {
filteredElements = [];
const selector = locator.selector;
elements.forEach((element) => {
const closest = this.getClosestMatching(element, selector, container);
if (closest) {
filteredElements.push(closest);
}
});
}
if (filteredElements.length > 0) {
return filteredElements;
}
} while (container !== topContainer && (container = this.getParentElement(container)) && container !== topContainer);
return [];
};
/**
* Make sure that an element is visible and wait to trigger the callback.
*
* @param element Element.
*/
protected static async ensureElementVisible(element: HTMLElement): Promise<DOMRect> {
const initialRect = element.getBoundingClientRect();
element.scrollIntoView(false);
return new Promise<DOMRect>((resolve): void => {
requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
if (initialRect.y !== rect.y) {
setTimeout(() => {
resolve(rect);
}, 300);
return;
}
resolve(rect);
});
});
};
/**
* Press an element.
*
* @param element Element to press.
*/
static async pressElement(element: HTMLElement): Promise<void> {
NgZone.run(async () => {
const blockKey = TestsBehatBlocking.block();
// Events don't bubble up across Shadow DOM boundaries, and some buttons
// may not work without doing this.
const parentElement = this.getParentElement(element);
if (parentElement && parentElement.matches('ion-button, ion-back-button')) {
element = parentElement;
}
const rect = await this.ensureElementVisible(element);
// Simulate a mouse click on the button.
const eventOptions: MouseEventInit = {
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true,
view: window,
cancelable: true,
};
// There are some buttons in the app that don't respond to click events, for example
// buttons using the core-supress-events directive. That's why we need to send both
// click and mouse events.
element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
setTimeout(() => {
element.dispatchEvent(new MouseEvent('mouseup', eventOptions));
element.click();
TestsBehatBlocking.unblock(blockKey);
}, 300);
});
}
/**
* Set an element value.
*
* @param element HTML to set.
* @param value Value to be set.
*/
static async setElementValue(element: HTMLElement, value: string): Promise<void> {
NgZone.run(async () => {
const blockKey = TestsBehatBlocking.block();
// Functions to get/set value depending on field type.
let setValue = (text: string) => {
element.innerHTML = text;
};
let getValue = () => element.innerHTML;
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
setValue = (text: string) => {
element.value = text;
};
getValue = () => element.value;
}
// Pretend we have cut and pasted the new text.
let event: InputEvent;
if (getValue() !== '') {
event = new InputEvent('input', {
bubbles: true,
view: window,
cancelable: true,
inputType: 'deleteByCut',
});
await CoreUtils.nextTick();
setValue('');
element.dispatchEvent(event);
}
if (value !== '') {
event = new InputEvent('input', {
bubbles: true,
view: window,
cancelable: true,
inputType: 'insertFromPaste',
data: value,
});
await CoreUtils.nextTick();
setValue(value);
element.dispatchEvent(event);
}
TestsBehatBlocking.unblock(blockKey);
});
}
}
type ElementsWithExact = {
element: HTMLElement;
exact: boolean;
};

View File

@ -0,0 +1,398 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { TestsBehatDomUtils } from './behat-dom';
import { TestsBehatBlocking } from './behat-blocking';
import { CoreCustomURLSchemes } from '@services/urlschemes';
import { CoreLoginHelperProvider } from '@features/login/services/login-helper';
import { CoreConfig } from '@services/config';
import { EnvironmentConfig } from '@/types/config';
/**
* Behat runtime servive with public API.
*/
export class TestsBehatRuntime {
/**
* Init behat functions and set options like skipping onboarding.
*
* @param options Options to set on the app.
*/
static init(options?: TestsBehatInitOptions): void {
TestsBehatBlocking.init();
(window as BehatTestsWindow).behat = {
closePopup: TestsBehatRuntime.closePopup,
find: TestsBehatRuntime.find,
getAngularInstance: TestsBehatRuntime.getAngularInstance,
getHeader: TestsBehatRuntime.getHeader,
isSelected: TestsBehatRuntime.isSelected,
loadMoreItems: TestsBehatRuntime.loadMoreItems,
log: TestsBehatRuntime.log,
press: TestsBehatRuntime.press,
pressStandard: TestsBehatRuntime.pressStandard,
scrollTo: TestsBehatRuntime.scrollTo,
setField: TestsBehatRuntime.setField,
handleCustomURL: TestsBehatRuntime.handleCustomURL,
};
if (!options) {
return;
}
if (options.skipOnBoarding === true) {
CoreConfig.set(CoreLoginHelperProvider.ONBOARDING_DONE, 1);
}
if (options.configOverrides) {
// Set the cookie so it's maintained between reloads.
document.cookie = 'MoodleAppConfig=' + JSON.stringify(options.configOverrides);
CoreConfig.patchEnvironment(options.configOverrides);
}
}
/**
* Handles a custom URL.
*
* @param url Url to open.
* @return OK if successful, or ERROR: followed by message.
*/
static async handleCustomURL(url: string): Promise<string> {
const blockKey = TestsBehatBlocking.block();
try {
await CoreCustomURLSchemes.handleCustomURL(url);
return 'OK';
} catch (error) {
return 'ERROR: ' + error.message;
} finally {
TestsBehatBlocking.unblock(blockKey);
}
}
/**
* Function to find and click an app standard button.
*
* @param button Type of button to press.
* @return OK if successful, or ERROR: followed by message.
*/
static pressStandard(button: string): string {
this.log('Action - Click standard button: ' + button);
// Find button
let foundButton: HTMLElement | undefined;
switch (button) {
case 'back':
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Back' });
break;
case 'main menu': // Deprecated name.
case 'more menu':
foundButton = TestsBehatDomUtils.findElementBasedOnText({
text: 'More',
selector: 'ion-tab-button',
});
break;
case 'user menu' :
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'User account' });
break;
case 'page menu':
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Display options' });
break;
default:
return 'ERROR: Unsupported standard button type';
}
if (!foundButton) {
return `ERROR: Button '${button}' not found`;
}
// Click button
TestsBehatDomUtils.pressElement(foundButton);
return 'OK';
}
/**
* When there is a popup, clicks on the backdrop.
*
* @return OK if successful, or ERROR: followed by message
*/
static closePopup(): string {
this.log('Action - Close popup');
let backdrops = Array.from(document.querySelectorAll('ion-backdrop'));
backdrops = backdrops.filter((backdrop) => !!backdrop.offsetParent);
if (!backdrops.length) {
return 'ERROR: Could not find backdrop';
}
if (backdrops.length > 1) {
return 'ERROR: Found too many backdrops';
}
const backdrop = backdrops[0];
backdrop.click();
// Mark busy until the click finishes processing.
TestsBehatBlocking.delay();
return 'OK';
}
/**
* Function to find an arbitrary element based on its text or aria label.
*
* @param locator Element locator.
* @param containerName Whether to search only inside a specific container content.
* @return OK if successful, or ERROR: followed by message
*/
static find(locator: TestBehatElementLocator, containerName: string): string {
this.log('Action - Find', { locator, containerName });
try {
const element = TestsBehatDomUtils.findElementBasedOnText(locator, containerName);
if (!element) {
return 'ERROR: No element matches locator to find.';
}
this.log('Action - Found', { locator, containerName, element });
return 'OK';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Scroll an element into view.
*
* @param locator Element locator.
* @return OK if successful, or ERROR: followed by message
*/
static scrollTo(locator: TestBehatElementLocator): string {
this.log('Action - scrollTo', { locator });
try {
let element = TestsBehatDomUtils.findElementBasedOnText(locator);
if (!element) {
return 'ERROR: No element matches element to scroll to.';
}
element = element.closest('ion-item') ?? element.closest('button') ?? element;
element.scrollIntoView();
this.log('Action - Scrolled to', { locator, element });
return 'OK';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Load more items form an active list with infinite loader.
*
* @return OK if successful, or ERROR: followed by message
*/
static async loadMoreItems(): Promise<string> {
this.log('Action - loadMoreItems');
try {
const infiniteLoading = Array
.from(document.querySelectorAll<HTMLElement>('core-infinite-loading'))
.find(element => !element.closest('.ion-page-hidden'));
if (!infiniteLoading) {
return 'ERROR: There isn\'t an infinite loader in the current page.';
}
const initialOffset = infiniteLoading.offsetTop;
const isLoading = () => !!infiniteLoading.querySelector('ion-spinner[aria-label]');
const isCompleted = () => !isLoading() && !infiniteLoading.querySelector('ion-button');
const hasMoved = () => infiniteLoading.offsetTop !== initialOffset;
if (isCompleted()) {
return 'ERROR: All items are already loaded.';
}
infiniteLoading.scrollIntoView({ behavior: 'smooth' });
// Wait 100ms
await new Promise(resolve => setTimeout(resolve, 100));
if (isLoading() || isCompleted() || hasMoved()) {
return 'OK';
}
infiniteLoading.querySelector<HTMLElement>('ion-button')?.click();
// Wait 100ms
await new Promise(resolve => setTimeout(resolve, 100));
return (isLoading() || isCompleted() || hasMoved()) ? 'OK' : 'ERROR: Couldn\'t load more items.';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Check whether an item is selected or not.
*
* @param locator Element locator.
* @return YES or NO if successful, or ERROR: followed by message
*/
static isSelected(locator: TestBehatElementLocator): string {
this.log('Action - Is Selected', locator);
try {
const element = TestsBehatDomUtils.findElementBasedOnText(locator);
return TestsBehatDomUtils.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 locator Element locator.
* @return OK if successful, or ERROR: followed by message
*/
static press(locator: TestBehatElementLocator): string {
this.log('Action - Press', locator);
try {
const found = TestsBehatDomUtils.findElementBasedOnText(locator);
if (!found) {
return 'ERROR: No element matches locator to press.';
}
TestsBehatDomUtils.pressElement(found);
return 'OK';
} catch (error) {
return 'ERROR: ' + error.message;
}
}
/**
* Gets the currently displayed page header.
*
* @return OK: followed by header text if successful, or ERROR: followed by message.
*/
static getHeader(): string {
this.log('Action - Get header');
let titles = Array.from(document.querySelectorAll<HTMLElement>('.ion-page:not(.ion-page-hidden) > ion-header h1'));
titles = titles.filter((title) => TestsBehatDomUtils.isElementVisible(title, document.body));
if (titles.length > 1) {
return 'ERROR: Too many possible titles.';
} else if (!titles.length) {
return 'ERROR: No title found.';
} else {
const title = titles[0].innerText.trim();
return 'OK:' + title;
}
}
/**
* Sets the text of a field to the specified value.
*
* This currently matches fields only based on the placeholder attribute.
*
* @param field Field name
* @param value New value
* @return OK or ERROR: followed by message
*/
static setField(field: string, value: string): string {
this.log('Action - Set field ' + field + ' to: ' + value);
const found: HTMLElement | HTMLInputElement | HTMLTextAreaElement =TestsBehatDomUtils.findElementBasedOnText(
{ text: field, selector: 'input, textarea, [contenteditable="true"]' },
);
if (!found) {
return 'ERROR: No element matches field to set.';
}
TestsBehatDomUtils.setElementValue(found, value);
return 'OK';
}
/**
* Get an Angular component instance.
*
* @param selector Element selector
* @param className Constructor class name
* @return Component instance
*/
static getAngularInstance(selector: string, className: string): unknown {
this.log('Action - Get Angular instance ' + selector + ', ' + className);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeElement = Array.from(document.querySelectorAll<any>(`.ion-page:not(.ion-page-hidden) ${selector}`)).pop();
if (!activeElement || !activeElement.__ngContext__) {
return null;
}
return activeElement.__ngContext__.find(node => node?.constructor?.name === className);
}
/**
* Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT'
* keyword so we can easily filter for it if needed.
*/
static log(...args: unknown[]): void {
const now = new Date();
const 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, ...args); // eslint-disable-line no-console
}
}
export type BehatTestsWindow = Window & {
M?: { // eslint-disable-line @typescript-eslint/naming-convention
util?: {
pending_js?: string[]; // eslint-disable-line @typescript-eslint/naming-convention
};
};
behatInit?: () => void;
behat?: unknown;
};
export type TestBehatElementLocator = {
text: string;
within?: TestBehatElementLocator;
near?: TestBehatElementLocator;
selector?: string;
};
export type TestsBehatInitOptions = {
skipOnBoarding?: boolean;
configOverrides?: Partial<EnvironmentConfig>;
};

View File

@ -0,0 +1,30 @@
@app @javascript
Feature: It navigates properly within activities.
Background:
Given the following "users" exist:
| username |
| student |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student | C1 | student |
And the following "activities" exist:
| activity | idnumber | course | name | intro | content |
| label | label | C1 | Label | Label description | - |
| page | page | C1 | Page | - | <a href="/mod/label/view.php?id=${label:cmid}">Go to label</a> |
And I replace the arguments in "page" "content"
Scenario: Navigates using deep links
Given I entered the course "Course 1" as "student" in the app
When I press "Page" in the app
And I press "Go to label" in the app
Then I should find "Label description" in the app
When I press the back button in the app
Then I should find "Go to label" in the app
When I press the back button in the app
Then I should find "Label description" in the app

View File

@ -0,0 +1,84 @@
@app @javascript
Feature: It navigates properly using deep links.
Background:
Given the following "users" exist:
| username |
| student1 |
| student2 |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student2 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| forum | Test forum | Test forum | C1 | forum |
And the following forum discussions exist in course "Course 1":
| forum | user | name | message |
| Test forum | student1 | Forum topic | Forum message |
And the following config values are set as admin:
| forcelogout | 1 | tool_mobile |
| defaulthomepage | 0 | |
Scenario: Receive a push notification
Given I entered the app as "student2"
When I press the user menu button in the app
And I press "Log out" in the app
And I wait the app to restart
And I press "Add" in the app
And I set the field "Your site" to "$WWWROOT" in the app
And I press "Connect to your site" in the app
And I log in as "student1"
And I receive a push notification in the app for:
| username | module | discussion |
| student2 | forum | Forum topic |
And I wait the app to restart
Then I should find "Reconnect" in the app
When I set the field "Password" to "student2" in the app
And I press "Log in" in the app
Then I should find "Forum topic" in the app
And I should find "Forum message" in the app
But I should not find "Site home" in the app
When I press the back button in the app
Then I should find "Site home" in the app
But I should not find "Forum topic" in the app
And I should not find "Forum message" in the app
Scenario: Open a link with a custom URL
When I launch the app
And I open a custom link in the app for:
| discussion |
| Forum topic |
And I log in as "student1"
And I wait loading to finish in the app
Then I should find "Forum topic" in the app
And I should find "Forum message" in the app
But I should not find "Site home" in the app
When I press the back button in the app
Then I should find "Site home" in the app
But I should not find "Forum topic" in the app
And I should not find "Forum message" in the app
Scenario: Open a link with a custom URL that calls WebServices for a logged out site
Given I entered the app as "student2"
When I press the user menu button in the app
And I press "Log out" in the app
And I wait the app to restart
And I open a custom link in the app for:
| forum |
| Test forum |
Then I should find "Reconnect" in the app
When I set the field "Password" to "student2" in the app
And I press "Log in" in the app
Then I should find "Test forum" in the app
When I press the back button in the app
Then I should find "Site home" in the app
But I should not find "Test forum" in the app

View File

@ -0,0 +1,46 @@
@app @javascript
Feature: It opens external links properly.
Background:
Given the following "users" exist:
| username |
| student1 |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | intro | course | idnumber |
| forum | Test forum | Test forum | C1 | forum |
And the following forum discussions exist in course "Course 1":
| forum | user | name | message |
| Test forum | student1 | Forum topic | See <a href="https://moodle.org/">moodle.org external link</a> |
Scenario: Click an external link
Given I entered the forum activity "Test forum" on course "Course 1" as "student1" in the app
When I press "Forum topic" in the app
And I press "moodle.org external link" in the app
Then I should find "You are about to leave the app" in the app
When I press "Cancel" in the app
And I press "moodle.org external link" in the app
And I press "OK" in the app
Then the app should have opened a browser tab with url "moodle.org"
When I close the browser tab opened by the app
And I press the back button in the app
And I press "Information" in the app
And I press "Open in browser" in the app
Then the app should have opened a browser tab
When I close the browser tab opened by the app
When I close the popup in the app
And I press "Forum topic" in the app
And I press "moodle.org external link" in the app
And I select "Don't show again." in the app
And I press "OK" in the app
And I close the browser tab opened by the app
And I press "moodle.org external link" in the app
Then the app should have opened a browser tab with url "moodle.org"

View File

@ -0,0 +1,59 @@
@app @javascript
Feature: It navigates using gestures.
Background:
Given the following "users" exist:
| username | firstname | lastname |
| student1 | Student | First |
| teacher1 | Teacher | First |
| student2 | Student | Second |
| teacher2 | Teacher | Second |
| student3 | Student | Third |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| teacher1 | C1 | teacher |
| student2 | C1 | student |
| teacher2 | C1 | teacher |
| student3 | C1 | student |
Scenario: Swipe between participants
Given I entered the course "Course 1" as "student1" in the app
When I press "Participants" in the app
And I press "Student First" in the app
And I swipe to the left in the app
Then I should find "Teacher First" in the app
When I swipe to the left in the app
Then I should find "Student Second" in the app
When I swipe to the right in the app
Then I should find "Teacher First" in the app
When I swipe to the right in the app
Then I should find "Student First" in the app
When I swipe to the right in the app
Then I should find "Student First" in the app
Scenario: Swipe between filtered participants
Given I entered the course "Course 1" as "student1" in the app
When I press "Participants" in the app
And I press "Search" in the app
And I set the field "Search" to "student" in the app
And I press "Search" "button" near "Clear search" in the app
And I press "Student First" in the app
And I swipe to the left in the app
Then I should find "Student Second" in the app
When I swipe to the left in the app
Then I should find "Student Third" in the app
When I swipe to the right in the app
Then I should find "Student Second" in the app
When I swipe to the right in the app
Then I should find "Student First" in the app

View File

@ -0,0 +1,109 @@
@app @javascript
Feature: It navigates properly in pages with a split-view component.
Background:
Given the following "users" exist:
| username |
| student1 |
And 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 in grades tab on mobile
# Open user menu
Given I entered the app as "student1"
And I press the user menu button in the app
# Open grades page
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 "Course 1" 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 I should find "20" near "Range" in the app
And I should find "40" near "Range" in the app
# Go back to grades page
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 "Course 2" 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 I should find "60" near "Range" in the app
And I should find "80" near "Range" in the app
# Go back to grades page
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 main page
When I press the back button in the app
Then I should find "Acceptance test site" in the app
And I should find "User account" in the app
But I should not find "Back" in the app
Scenario: Navigate in grades tab on tablet
# Open user menu
Given I entered the app as "student1"
And I change viewport size to "1200x640"
And I press the user menu button in the app
# Open grades page
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 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
# Select C2 course
When I press "Course 2" in the app
Then "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 I should find "60" near "Range" in the app
And I should find "80" near "Range" in the app
# Go back to main page
When I press the back button in the app
Then I should find "Acceptance test site" in the app
And I should find "User account" in the app
But I should not find "Back" in the app

View File

@ -81,13 +81,7 @@ Feature: Measure performance.
Then "Login" should have taken less than 10 seconds Then "Login" should have taken less than 10 seconds
Scenario: Open Activity Scenario: Open Activity
When I launch the app Given I entered the app as "student1"
Then I should see "Connect to Moodle"
But I should not see "Welcome to the Moodle App!"
And I set the field "Your site" to "$WWWROOT" in the app
And I press "Connect to your site" in the app
And I log in as "student1"
Then I press "My courses" in the app Then I press "My courses" in the app
And I should find "Course 1" in the app And I should find "Course 1" in the app

View File

@ -39,6 +39,7 @@ export interface EnvironmentConfig {
wsservice: string; wsservice: string;
demo_sites: Record<string, CoreSitesDemoSiteData>; demo_sites: Record<string, CoreSitesDemoSiteData>;
zoomlevels: Record<CoreZoomLevel, number>; zoomlevels: Record<CoreZoomLevel, number>;
defaultZoomLevel?: CoreZoomLevel; // Set the default zoom level of the app.
customurlscheme: string; customurlscheme: string;
siteurl: string; siteurl: string;
sitename: string; sitename: string;

View File

View File

@ -1,6 +1,10 @@
This files describes API changes in the Moodle Mobile app, This files describes API changes in the Moodle Mobile app,
information provided here is intended especially for developers. information provided here is intended especially for developers.
=== 4.0.1 ===
- Zoom levels changed from "normal / low / high" to " none / medium / high".
=== 4.0.0 === === 4.0.0 ===
- The versioncode in moodle.config.json has changed from 4 digits to 5 digits to match the actual value for the stores: the 4.0.0 version's versioncode is now 40000 instead of 4000. If you maintain a Moodle plugin with mobile support and you use the versioncode that is sent in every request, you might need to check if this change will affect your code. - The versioncode in moodle.config.json has changed from 4 digits to 5 digits to match the actual value for the stores: the 4.0.0 version's versioncode is now 40000 instead of 4000. If you maintain a Moodle plugin with mobile support and you use the versioncode that is sent in every request, you might need to check if this change will affect your code.