diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml
new file mode 100644
index 000000000..ba61f5aa3
--- /dev/null
+++ b/.github/workflows/acceptance.yml
@@ -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"
diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml
index 2652fda51..aafc955ec 100644
--- a/.github/workflows/performance.yml
+++ b/.github/workflows/performance.yml
@@ -1,6 +1,6 @@
name: Performance
-on: [push, pull_request]
+on: [ pull_request, workflow_dispatch ]
jobs:
performance:
@@ -19,7 +19,6 @@ jobs:
- name: Additional checkouts
run: |
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
- name: Install npm packages
run: |
diff --git a/gulpfile.js b/gulpfile.js
index 31db54239..7c7c5bb96 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -71,5 +71,5 @@ gulp.task('watch', () => {
});
gulp.task('watch-behat', () => {
- gulp.watch(['./tests/behat'], { interval: 500 }, gulp.parallel('behat'));
+ gulp.watch(['./src/**/*.feature', './local-moodleappbehat'], { interval: 500 }, gulp.parallel('behat'));
});
diff --git a/local-moodleappbehat/classes/output/mobile.php b/local-moodleappbehat/classes/output/mobile.php
new file mode 100644
index 000000000..bddc07bd8
--- /dev/null
+++ b/local-moodleappbehat/classes/output/mobile.php
@@ -0,0 +1,42 @@
+.
+
+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' => '
Hello!
',
+ ],
+ ];
+
+ $javascript = file_get_contents(__DIR__ . '/../../js/mobile/index.js');
+
+ return compact('templates', 'javascript');
+ }
+
+}
diff --git a/scripts/templates/behat-plugin/classes/privacy/provider.php b/local-moodleappbehat/classes/privacy/provider.php
similarity index 100%
rename from scripts/templates/behat-plugin/classes/privacy/provider.php
rename to local-moodleappbehat/classes/privacy/provider.php
diff --git a/local-moodleappbehat/db/mobile.php b/local-moodleappbehat/db/mobile.php
new file mode 100644
index 000000000..c829fa09d
--- /dev/null
+++ b/local-moodleappbehat/db/mobile.php
@@ -0,0 +1,34 @@
+.
+
+$addons = [
+ 'local_moodleappbehat' => [
+ 'handlers' => [
+ 'index' => [
+ 'delegate' => 'CoreMainMenuDelegate',
+ 'method' => 'view_index',
+ 'displaydata' => [
+ 'title' => 'pluginname',
+ 'icon' => 'language',
+ ],
+ ],
+ ],
+ 'lang' => [
+ ['pluginname', 'local_moodleappbehat'],
+ ],
+ ],
+];
diff --git a/local-moodleappbehat/js/mobile/index.js b/local-moodleappbehat/js/mobile/index.js
new file mode 100644
index 000000000..4576f5024
--- /dev/null
+++ b/local-moodleappbehat/js/mobile/index.js
@@ -0,0 +1,5 @@
+this.CoreSitesProvider.getSite().then(site => {
+ const username = site.infos.username;
+
+ document.getElementById('username').innerText = `, ${username}`;
+});
diff --git a/scripts/templates/behat-plugin/lang/en/local_moodleappbehat.php b/local-moodleappbehat/lang/en/local_moodleappbehat.php
similarity index 100%
rename from scripts/templates/behat-plugin/lang/en/local_moodleappbehat.php
rename to local-moodleappbehat/lang/en/local_moodleappbehat.php
diff --git a/local-moodleappbehat/tests/behat/behat_app.php b/local-moodleappbehat/tests/behat/behat_app.php
new file mode 100644
index 000000000..524ed7e3b
--- /dev/null
+++ b/local-moodleappbehat/tests/behat/behat_app.php
@@ -0,0 +1,800 @@
+.
+
+// 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;
+ }
+
+ if (!$this->is_in_login_page()) {
+ // Already in the site.
+ return;
+ }
+
+ global $CFG;
+
+ $this->i_set_the_field_in_the_app('Your site', $CFG->behat_wwwroot);
+ $this->i_press_in_the_app('"Connect to your site"');
+ $this->wait_for_pending_js();
+ }
+
+ /**
+ * 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();
+ }
+
+ /**
+ * 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[^"]+)")?$/
+ * @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);");
+ }
+
+}
diff --git a/local-moodleappbehat/tests/behat/behat_app_helper.php b/local-moodleappbehat/tests/behat/behat_app_helper.php
new file mode 100644
index 000000000..fc30cbed3
--- /dev/null
+++ b/local-moodleappbehat/tests/behat/behat_app_helper.php
@@ -0,0 +1,619 @@
+.
+
+// 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;
+
+ /**
+ * 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->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 = <<get_record_sql($sql, [
+ 'modname' => $activity,
+ 'idnumber' => $identifier,
+ 'name' => $identifier,
+ 'courseid' => $courseid,
+ ], MUST_EXIST);
+
+ return get_fast_modinfo($courseid)->get_cm($result->cmid);
+ }
+}
diff --git a/tests/behat/behat_performance.php b/local-moodleappbehat/tests/behat/behat_performance.php
similarity index 100%
rename from tests/behat/behat_performance.php
rename to local-moodleappbehat/tests/behat/behat_performance.php
diff --git a/tests/behat/classes/performance_measure.php b/local-moodleappbehat/tests/behat/classes/performance_measure.php
similarity index 99%
rename from tests/behat/classes/performance_measure.php
rename to local-moodleappbehat/tests/behat/classes/performance_measure.php
index 4e3c8ee7d..f56125ae5 100644
--- a/tests/behat/classes/performance_measure.php
+++ b/local-moodleappbehat/tests/behat/classes/performance_measure.php
@@ -18,6 +18,8 @@ use Behat\Mink\Exception\DriverException;
use Facebook\WebDriver\Exception\InvalidArgumentException;
use Moodle\BehatExtension\Driver\WebDriver;
+require_once(__DIR__ . '/../behat_app.php');
+
/**
* Performance measures for one particular metric.
*/
@@ -334,7 +336,7 @@ class performance_measure implements behat_app_listener {
" ],",
" ],",
" ],",
- ");",
+ "];",
"",
])
);
diff --git a/scripts/templates/behat-plugin/version.php b/local-moodleappbehat/version.php
similarity index 100%
rename from scripts/templates/behat-plugin/version.php
rename to local-moodleappbehat/version.php
diff --git a/scripts/build-behat-plugin.js b/scripts/build-behat-plugin.js
index 9d12c355d..1d0570d7b 100755
--- a/scripts/build-behat-plugin.js
+++ b/scripts/build-behat-plugin.js
@@ -46,23 +46,20 @@ async function main() {
// Copy plugin template.
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 = {
appVersion,
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));
- }
-
- // Copy plugin files.
- copySync(projectPath('tests/behat'), `${pluginPath}/tests/behat`);
+ writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements));
// Copy feature files.
const behatTempFeaturesPath = `${pluginPath}/behat-tmp`;
@@ -80,7 +77,8 @@ async function main() {
}
const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length));
- const prefix = relative(behatTempFeaturesPath, newPath).replace('/','-') || 'core';
+ const searchRegExp = new RegExp('/', 'g');
+ const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core';
const featureFilename = prefix + '-' + basename(featureFile);
renameSync(featureFile, behatFeaturesPath + '/' + featureFilename);
}
diff --git a/src/addons/messages/tests/behat/basic_usage.feature b/src/addons/messages/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..c84ed18fd
--- /dev/null
+++ b/src/addons/messages/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/addons/messages/tests/behat/navigation.feature b/src/addons/messages/tests/behat/navigation.feature
new file mode 100644
index 000000000..633d564f2
--- /dev/null
+++ b/src/addons/messages/tests/behat/navigation.feature
@@ -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
diff --git a/src/addons/messages/tests/behat/settings.feature b/src/addons/messages/tests/behat/settings.feature
new file mode 100644
index 000000000..ba3fb76f0
--- /dev/null
+++ b/src/addons/messages/tests/behat/settings.feature
@@ -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
diff --git a/src/addons/mod/assign/tests/behat/basic_usage-310.feature b/src/addons/mod/assign/tests/behat/basic_usage-310.feature
new file mode 100755
index 000000000..532f7a134
--- /dev/null
+++ b/src/addons/mod/assign/tests/behat/basic_usage-310.feature
@@ -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
diff --git a/src/addons/mod/assign/tests/behat/basic_usage.feature b/src/addons/mod/assign/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..ec41e1017
--- /dev/null
+++ b/src/addons/mod/assign/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/addons/mod/assign/tests/behat/navigation.feature b/src/addons/mod/assign/tests/behat/navigation.feature
new file mode 100644
index 000000000..487a9c34d
--- /dev/null
+++ b/src/addons/mod/assign/tests/behat/navigation.feature
@@ -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
diff --git a/src/addons/mod/chat/tests/behat/basic_usage.feature b/src/addons/mod/chat/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..35a4972f6
--- /dev/null
+++ b/src/addons/mod/chat/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/addons/mod/chat/tests/behat/navigation.feature b/src/addons/mod/chat/tests/behat/navigation.feature
new file mode 100644
index 000000000..3490ea6f0
--- /dev/null
+++ b/src/addons/mod/chat/tests/behat/navigation.feature
@@ -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
diff --git a/src/addons/mod/choice/tests/behat/basic_usage-311.feature b/src/addons/mod/choice/tests/behat/basic_usage-311.feature
new file mode 100755
index 000000000..5030a7f2e
--- /dev/null
+++ b/src/addons/mod/choice/tests/behat/basic_usage-311.feature
@@ -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
diff --git a/src/addons/mod/choice/tests/behat/basic_usage.feature b/src/addons/mod/choice/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..753181599
--- /dev/null
+++ b/src/addons/mod/choice/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/addons/mod/forum/tests/behat/basic_usage.feature b/src/addons/mod/forum/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..ddf3e4990
--- /dev/null
+++ b/src/addons/mod/forum/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/addons/mod/forum/tests/behat/navigation.feature b/src/addons/mod/forum/tests/behat/navigation.feature
new file mode 100644
index 000000000..f3a9a4f7a
--- /dev/null
+++ b/src/addons/mod/forum/tests/behat/navigation.feature
@@ -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
diff --git a/src/addons/mod/glossary/tests/behat/navigation.feature b/src/addons/mod/glossary/tests/behat/navigation.feature
new file mode 100644
index 000000000..77d1bd1b8
--- /dev/null
+++ b/src/addons/mod/glossary/tests/behat/navigation.feature
@@ -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
diff --git a/src/addons/mod/quiz/tests/behat/basic_usage.feature b/src/addons/mod/quiz/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..73479ebc3
--- /dev/null
+++ b/src/addons/mod/quiz/tests/behat/basic_usage.feature
@@ -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"
diff --git a/src/addons/mod/quiz/tests/behat/quiz_navigation.feature b/src/addons/mod/quiz/tests/behat/quiz_navigation.feature
new file mode 100644
index 000000000..8ecb4e422
--- /dev/null
+++ b/src/addons/mod/quiz/tests/behat/quiz_navigation.feature
@@ -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
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 6ae88d2a5..82928a691 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -32,6 +32,7 @@ import { JitCompilerFactory } from '@angular/platform-browser-dynamic';
import { CoreCronDelegate } from '@services/cron';
import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron';
import { moodleTransitionAnimation } from '@classes/page-transition';
+import { BehatTestingModule } from '@/testing/behat-testing.module';
// For translate loader. AoT requires an exported function for factories.
export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
@@ -59,6 +60,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
AppRoutingModule,
CoreModule,
AddonsModule,
+ BehatTestingModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
diff --git a/src/core/components/navbar-buttons/navbar-buttons.ts b/src/core/components/navbar-buttons/navbar-buttons.ts
index 77878e67c..88dd4dcd0 100644
--- a/src/core/components/navbar-buttons/navbar-buttons.ts
+++ b/src/core/components/navbar-buttons/navbar-buttons.ts
@@ -208,10 +208,17 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy {
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.
- const header = parentPage?.querySelector(':scope > ion-header');
+ let header = parentPage?.querySelector(':scope > ion-header');
+
+ if (header && getComputedStyle(header).display !== 'none') {
+ return header;
+ }
+
+ // Find using content if any.
+ header = content?.parentElement?.querySelector(':scope > ion-header');
if (header && getComputedStyle(header).display !== 'none') {
return header;
diff --git a/src/core/features/course/tests/behat/basic_usage-311.feature b/src/core/features/course/tests/behat/basic_usage-311.feature
new file mode 100755
index 000000000..dd88d8151
--- /dev/null
+++ b/src/core/features/course/tests/behat/basic_usage-311.feature
@@ -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
diff --git a/src/core/features/course/tests/behat/basic_usage.feature b/src/core/features/course/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..9be9bcf6d
--- /dev/null
+++ b/src/core/features/course/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/core/features/course/tests/behat/course_completion-310.feature b/src/core/features/course/tests/behat/course_completion-310.feature
new file mode 100644
index 000000000..566aa109c
--- /dev/null
+++ b/src/core/features/course/tests/behat/course_completion-310.feature
@@ -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
diff --git a/src/core/features/course/tests/behat/course_completion.feature b/src/core/features/course/tests/behat/course_completion.feature
new file mode 100644
index 000000000..32d9a267c
--- /dev/null
+++ b/src/core/features/course/tests/behat/course_completion.feature
@@ -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
diff --git a/src/core/features/course/tests/behat/courselist-311.feature b/src/core/features/course/tests/behat/courselist-311.feature
new file mode 100644
index 000000000..4cdc5fe08
--- /dev/null
+++ b/src/core/features/course/tests/behat/courselist-311.feature
@@ -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
diff --git a/src/core/features/course/tests/behat/courselist.feature b/src/core/features/course/tests/behat/courselist.feature
new file mode 100644
index 000000000..f1d99e1dc
--- /dev/null
+++ b/src/core/features/course/tests/behat/courselist.feature
@@ -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
diff --git a/src/core/features/courses/tests/behat/basic_usage-310.feature b/src/core/features/courses/tests/behat/basic_usage-310.feature
new file mode 100755
index 000000000..8b28fede8
--- /dev/null
+++ b/src/core/features/courses/tests/behat/basic_usage-310.feature
@@ -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
diff --git a/src/core/features/courses/tests/behat/basic_usage-311.feature b/src/core/features/courses/tests/behat/basic_usage-311.feature
new file mode 100755
index 000000000..91589822b
--- /dev/null
+++ b/src/core/features/courses/tests/behat/basic_usage-311.feature
@@ -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
diff --git a/src/core/features/courses/tests/behat/basic_usage.feature b/src/core/features/courses/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..14b58707b
--- /dev/null
+++ b/src/core/features/courses/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/core/features/login/tests/behat/basic_usage.feature b/src/core/features/login/tests/behat/basic_usage.feature
new file mode 100755
index 000000000..a558d1f74
--- /dev/null
+++ b/src/core/features/login/tests/behat/basic_usage.feature
@@ -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
diff --git a/src/core/features/mainmenu/tests/behat/mainmenu-311.feature b/src/core/features/mainmenu/tests/behat/mainmenu-311.feature
new file mode 100644
index 000000000..7de9fdce9
--- /dev/null
+++ b/src/core/features/mainmenu/tests/behat/mainmenu-311.feature
@@ -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"
diff --git a/src/core/features/mainmenu/tests/behat/mainmenu.feature b/src/core/features/mainmenu/tests/behat/mainmenu.feature
new file mode 100644
index 000000000..d69a64651
--- /dev/null
+++ b/src/core/features/mainmenu/tests/behat/mainmenu.feature
@@ -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"
diff --git a/src/core/features/settings/tests/behat/settings_navigation.feature b/src/core/features/settings/tests/behat/settings_navigation.feature
new file mode 100644
index 000000000..9096b1138
--- /dev/null
+++ b/src/core/features/settings/tests/behat/settings_navigation.feature
@@ -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
diff --git a/src/core/features/siteplugins/tests/behat/plugins.feature b/src/core/features/siteplugins/tests/behat/plugins.feature
new file mode 100644
index 000000000..1c5dca648
--- /dev/null
+++ b/src/core/features/siteplugins/tests/behat/plugins.feature
@@ -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
diff --git a/src/core/features/usertours/tests/behat/usertours.feature b/src/core/features/usertours/tests/behat/usertours.feature
new file mode 100644
index 000000000..4139db45a
--- /dev/null
+++ b/src/core/features/usertours/tests/behat/usertours.feature
@@ -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
diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts
index 64c5af804..9cddd8b96 100644
--- a/src/core/services/sites.ts
+++ b/src/core/services/sites.ts
@@ -557,14 +557,14 @@ export class CoreSitesProvider {
}
// Add site to sites list.
- this.addSite(siteId, siteUrl, token, info, privateToken, config, oauthId);
+ await this.addSite(siteId, siteUrl, token, info, privateToken, config, oauthId);
this.sites[siteId] = candidateSite;
if (login) {
// Turn candidate site into current site.
this.currentSite = candidateSite;
// Store session.
- this.login(siteId);
+ await this.login(siteId);
} else if (this.currentSite && this.currentSite.getId() == siteId) {
// Current site has just been updated, trigger the event.
CoreEvents.trigger(CoreEvents.SITE_UPDATED, info, siteId);
diff --git a/src/testing/behat-testing.module.prod.ts b/src/testing/behat-testing.module.prod.ts
new file mode 100644
index 000000000..a95d24c81
--- /dev/null
+++ b/src/testing/behat-testing.module.prod.ts
@@ -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 {}
diff --git a/src/testing/behat-testing.module.ts b/src/testing/behat-testing.module.ts
new file mode 100644
index 000000000..11bfde709
--- /dev/null
+++ b/src/testing/behat-testing.module.ts
@@ -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 {}
diff --git a/src/testing/services/behat-blocking.ts b/src/testing/services/behat-blocking.ts
new file mode 100644
index 000000000..913ba00ab
--- /dev/null
+++ b/src/testing/services/behat-blocking.ts
@@ -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 {
+ // 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 {
+ 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 {
+ await CoreUtils.nextTick();
+ const blocked = document.querySelector('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);
diff --git a/src/testing/services/behat-dom.ts b/src/testing/services/behat-dom.ts
new file mode 100644
index 000000000..761806d69
--- /dev/null
+++ b/src/testing/services/behat-dom.ts
@@ -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(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(`#${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('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(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('.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('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 {
+ const initialRect = element.getBoundingClientRect();
+
+ element.scrollIntoView(false);
+
+ return new Promise((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 {
+ 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 {
+ 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;
+};
diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts
new file mode 100644
index 000000000..97ba8a313
--- /dev/null
+++ b/src/testing/services/behat-runtime.ts
@@ -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 {
+ 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 {
+ this.log('Action - loadMoreItems');
+
+ try {
+ const infiniteLoading = Array
+ .from(document.querySelectorAll('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('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('.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(`.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;
+};
diff --git a/src/tests/behat/navigation_activities.feature b/src/tests/behat/navigation_activities.feature
new file mode 100644
index 000000000..c90b1fc6b
--- /dev/null
+++ b/src/tests/behat/navigation_activities.feature
@@ -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 | - | Go to label |
+ 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
diff --git a/src/tests/behat/navigation_deeplinks.feature b/src/tests/behat/navigation_deeplinks.feature
new file mode 100644
index 000000000..4394a477f
--- /dev/null
+++ b/src/tests/behat/navigation_deeplinks.feature
@@ -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
diff --git a/src/tests/behat/navigation_externallinks.feature b/src/tests/behat/navigation_externallinks.feature
new file mode 100644
index 000000000..b68264704
--- /dev/null
+++ b/src/tests/behat/navigation_externallinks.feature
@@ -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 moodle.org external link |
+
+ 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"
diff --git a/src/tests/behat/navigation_gestures.feature b/src/tests/behat/navigation_gestures.feature
new file mode 100644
index 000000000..7b02fd07a
--- /dev/null
+++ b/src/tests/behat/navigation_gestures.feature
@@ -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
diff --git a/src/tests/behat/navigation_splitview.feature b/src/tests/behat/navigation_splitview.feature
new file mode 100644
index 000000000..74f331fbb
--- /dev/null
+++ b/src/tests/behat/navigation_splitview.feature
@@ -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
diff --git a/src/tests/behat/performance.feature b/src/tests/behat/performance.feature
index 39a41c5d1..950dfee4e 100644
--- a/src/tests/behat/performance.feature
+++ b/src/tests/behat/performance.feature
@@ -81,13 +81,7 @@ Feature: Measure performance.
Then "Login" should have taken less than 10 seconds
Scenario: Open Activity
- When I launch the app
- 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"
+ Given I entered the app as "student1"
Then I press "My courses" in the app
And I should find "Course 1" in the app