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/config.xml b/config.xml index 9b72ead21..8dd6def1f 100644 --- a/config.xml +++ b/config.xml @@ -1,5 +1,5 @@ - + Moodle Moodle official app Moodle Mobile team @@ -27,7 +27,7 @@ - + @@ -251,7 +251,7 @@ - 4.0.1 + 4.1.0 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..45dbaec51 --- /dev/null +++ b/local-moodleappbehat/tests/behat/behat_app.php @@ -0,0 +1,807 @@ +. + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. + +require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); +require_once(__DIR__ . '/behat_app_helper.php'); + +use Behat\Gherkin\Node\TableNode; +use Behat\Mink\Exception\DriverException; +use Behat\Mink\Exception\ExpectationException; + +/** + * Moodle App steps definitions. + * + * @package core + * @category test + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_app extends behat_app_helper { + + /** @var string URL for running Ionic server */ + protected $ionicurl = ''; + + /** @var array Config overrides */ + protected $appconfig = ['disableUserTours' => true]; + + protected $windowsize = '360x720'; + + /** + * Opens the Moodle App in the browser and optionally logs in. + * + * @When I enter the app + * @Given I entered the app as :username + * @throws DriverException Issue with configuration or feature file + * @throws dml_exception Problem with Moodle setup + * @throws ExpectationException Problem with resizing window + */ + public function i_enter_the_app(string $username = null) { + $this->i_launch_the_app(); + + if (!is_null($username)) { + $this->open_moodleapp_custom_login_url($username); + + return; + } + + $this->enter_site(); + } + + /** + * Check whether the current page is the login form. + */ + protected function is_in_login_page(): bool { + $page = $this->getSession()->getPage(); + $logininput = $page->find('xpath', '//page-core-login-site//input[@name="url"]'); + + return !is_null($logininput); + } + + /** + * Opens the Moodle App in the browser. + * + * @When I launch the app :runtime + * @When I launch the app + * @throws DriverException Issue with configuration or feature file + * @throws dml_exception Problem with Moodle setup + * @throws ExpectationException Problem with resizing window + */ + public function i_launch_the_app(string $runtime = '') { + // Check the app tag was set. + if (!$this->has_tag('app')) { + throw new DriverException('Requires @app tag on scenario or feature.'); + } + + // Go to page and prepare browser for app. + $this->prepare_browser(['skiponboarding' => empty($runtime)]); + } + + /** + * @Then I wait the app to restart + */ + public function i_wait_the_app_to_restart() { + // Wait window to reload. + $this->spin(function() { + $result = $this->evaluate_script("return !window.behat;"); + + if (!$result) { + throw new DriverException('Window is not reloading properly.'); + } + + return true; + }); + + // Prepare testing runtime again. + $this->prepare_browser(['restart' => false]); + } + + /** + * Finds elements in the app. + * + * @Then /^I should( not)? find (".+")( inside the .+)? in the app$/ + */ + public function i_find_in_the_app(bool $not, string $locator, string $containerName = '') { + $locator = $this->parse_element_locator($locator); + if (!empty($containerName)) { + preg_match('/^ inside the (.+)$/', $containerName, $matches); + $containerName = $matches[1]; + } + $containerName = json_encode($containerName); + + $this->spin(function() use ($not, $locator, $containerName) { + $result = $this->evaluate_script("return window.behat.find($locator, $containerName);"); + + if ($not && $result === 'OK') { + throw new DriverException('Error, found an item that should not be found'); + } + + if (!$not && $result !== 'OK') { + throw new DriverException('Error finding item - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + + /** + * Scroll to an element in the app. + * + * @When /^I scroll to (".+") in the app$/ + * @param string $locator + */ + public function i_scroll_to_in_the_app(string $locator) { + $locator = $this->parse_element_locator($locator); + + $this->spin(function() use ($locator) { + $result = $this->evaluate_script("return window.behat.scrollTo($locator);"); + + if ($result !== 'OK') { + throw new DriverException('Error finding item - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + + // Wait scroll animation to finish. + $this->getSession()->wait(300); + } + + /** + * Load more items in a list with an infinite loader. + * + * @When /^I (should not be able to )?load more items in the app$/ + * @param bool $not + */ + public function i_load_more_items_in_the_app(bool $not = false) { + $this->spin(function() use ($not) { + $result = $this->evaluate_async_script('return window.behat.loadMoreItems();'); + + if ($not && $result !== 'ERROR: All items are already loaded.') { + throw new DriverException('It should not have been possible to load more items'); + } + + if (!$not && $result !== 'OK') { + throw new DriverException('Error loading more items - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + + /** + * Trigger swipe gesture. + * + * @When /^I swipe to the (left|right) in the app$/ + * @param string $direction + */ + public function i_swipe_in_the_app(string $direction) { + $method = 'swipe' . ucwords($direction); + + $this->evaluate_script("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); + + $this->wait_for_pending_js(); + + // Wait swipe animation to finish. + $this->getSession()->wait(300); + } + + /** + * Check if elements are selected in the app. + * + * @Then /^(".+") should( not)? be selected in the app$/ + * @param string $locator + * @param bool $not + */ + public function be_selected_in_the_app(string $locator, bool $not = false) { + $locator = $this->parse_element_locator($locator); + + $this->spin(function() use ($locator, $not) { + $result = $this->evaluate_script("return window.behat.isSelected($locator);"); + + switch ($result) { + case 'YES': + if ($not) { + throw new ExpectationException("Item was selected and shouldn't have", $this->getSession()->getDriver()); + } + break; + case 'NO': + if (!$not) { + throw new ExpectationException("Item wasn't selected and should have", $this->getSession()->getDriver()); + } + break; + default: + throw new DriverException('Error finding item - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + + /** + * Carries out the login steps for the app, assuming the user is on the app login page. Called + * from behat_auth.php. + * + * @param string $username Username (and password) + * @throws Exception Any error + */ + public function login(string $username) { + $this->i_set_the_field_in_the_app('Username', $username); + $this->i_set_the_field_in_the_app('Password', $username); + + // Note there are two 'Log in' texts visible (the title and the button) so we have to use + // a 'near' value here. + $this->i_press_in_the_app('"Log in" near "Forgotten"'); + + // Wait until the main page appears. + $this->spin( + function($context, $args) { + $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu'); + if ($mainmenu) { + return true; + } + throw new DriverException('Moodle App main page not loaded after login'); + }, false, 30); + + // Wait for JS to finish as well. + $this->wait_for_pending_js(); + } + + /** + * Enter site. + */ + protected function enter_site() { + if (!$this->is_in_login_page()) { + // Already in the site. + return; + } + + global $CFG; + + $this->i_set_the_field_in_the_app('Your site', $CFG->wwwroot); + $this->i_press_in_the_app('"Connect to your site"'); + $this->wait_for_pending_js(); + } + + /** + * Shortcut to let the user enter a course in the app. + * + * @Given I entered the course :coursename as :username in the app + * @Given I entered the course :coursename in the app + * @param string $coursename Course name + * @throws DriverException If the button push doesn't work + */ + public function i_entered_the_course_in_the_app(string $coursename, ?string $username = null) { + $courseid = $this->get_course_id($coursename); + if (!$courseid) { + throw new DriverException("Course '$coursename' not found"); + } + + if ($username) { + $this->i_launch_the_app(); + + $this->open_moodleapp_custom_login_url($username, "/course/view.php?id=$courseid", '//page-core-course-index'); + } else { + $this->open_moodleapp_custom_url("/course/view.php?id=$courseid", '//page-core-course-index'); + } + } + + /** + * User enters a course in the app. + * + * @Given I enter the course :coursename in the app + * @param string $coursename Course name + * @throws DriverException If the button push doesn't work + */ + public function i_enter_the_course_in_the_app(string $coursename, ?string $username = null) { + if (!is_null($username)) { + $this->i_enter_the_app(); + $this->login($username); + } + + $mycoursesfound = $this->evaluate_script("return window.behat.find({ text: 'My courses', selector: 'ion-tab-button'});"); + + if ($mycoursesfound !== 'OK') { + // My courses not present enter from Dashboard. + $this->i_press_in_the_app('"Home" "ion-tab-button"'); + $this->i_press_in_the_app('"Dashboard"'); + $this->i_press_in_the_app('"'.$coursename.'" near "Course overview"'); + + $this->wait_for_pending_js(); + + return; + } + + $this->i_press_in_the_app('"My courses" "ion-tab-button"'); + $this->i_press_in_the_app('"'.$coursename.'"'); + + $this->wait_for_pending_js(); + } + + /** + * User enters an activity in a course in the app. + * + * @Given I entered the :activity activity :activityname on course :course as :username in the app + * @Given I entered the :activity activity :activityname on course :course in the app + * @throws DriverException If the button push doesn't work + */ + public function i_enter_the_activity_in_the_app(string $activity, string $activityname, string $coursename, ?string $username = null) { + $cm = $this->get_cm_by_activity_name_and_course($activity, $activityname, $coursename); + if (!$cm) { + throw new DriverException("'$activityname' activity '$activityname' not found"); + } + + $pageurl = "/mod/$activity/view.php?id={$cm->id}"; + + if ($username) { + $this->i_launch_the_app(); + + $this->open_moodleapp_custom_login_url($username, $pageurl); + } else { + $this->open_moodleapp_custom_url($pageurl); + } + } + + /** + * Presses standard buttons in the app. + * + * @When /^I press the (back|more menu|page menu|user menu|main menu) button in the app$/ + * @param string $button Button type + * @throws DriverException If the button push doesn't work + */ + public function i_press_the_standard_button_in_the_app(string $button) { + $this->spin(function() use ($button) { + $result = $this->evaluate_script("return window.behat.pressStandard('$button');"); + + if ($result !== 'OK') { + throw new DriverException('Error pressing standard button - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + + /** + * Receives push notifications. + * + * @When /^I receive a push notification in the app for:$/ + * @param TableNode $data + */ + public function i_receive_a_push_notification(TableNode $data) { + global $DB, $CFG; + + $data = (object) $data->getColumnsHash()[0]; + $module = $DB->get_record('course_modules', ['idnumber' => $data->module]); + $discussion = $DB->get_record('forum_discussions', ['name' => $data->discussion]); + $notification = json_encode([ + 'site' => md5($CFG->behat_wwwroot . $data->username), + 'courseid' => $discussion->course, + 'moodlecomponent' => 'mod_forum', + 'name' => 'posts', + 'contexturl' => '', + 'notif' => 1, + 'customdata' => [ + 'discussionid' => $discussion->id, + 'cmid' => $module->id, + 'instance' => $discussion->forum, + ], + ]); + + $this->evaluate_script("return window.pushNotifications.notificationClicked($notification)"); + $this->wait_for_pending_js(); + } + + /** + * Replace arguments from the content in the given activity field. + * + * @Given /^I replace the arguments in "([^"]+)" "([^"]+)"$/ + */ + public function i_replace_arguments_in_the_activity(string $idnumber, string $field) { + global $DB; + + $coursemodule = $DB->get_record('course_modules', compact('idnumber')); + $module = $DB->get_record('modules', ['id' => $coursemodule->module]); + $activity = $DB->get_record($module->name, ['id' => $coursemodule->instance]); + + $DB->update_record($module->name, [ + 'id' => $coursemodule->instance, + $field => $this->replace_arguments($activity->{$field}), + ]); + } + + /** + * Opens a custom link. + * + * @Given /^I open a custom link in the app for:$/ + * @param TableNode $data + */ + public function i_open_a_custom_link(TableNode $data) { + global $DB; + + $data = $data->getColumnsHash()[0]; + $title = array_keys($data)[0]; + $data = (object) $data; + + switch ($title) { + case 'discussion': + $discussion = $DB->get_record('forum_discussions', ['name' => $data->discussion]); + $pageurl = "/mod/forum/discuss.php?d={$discussion->id}"; + + break; + + case 'assign': + case 'bigbluebuttonbn': + case 'book': + case 'chat': + case 'choice': + case 'data': + case 'feedback': + case 'folder': + case 'forum': + case 'glossary': + case 'h5pactivity': + case 'imscp': + case 'label': + case 'lesson': + case 'lti': + case 'page': + case 'quiz': + case 'resource': + case 'scorm': + case 'survey': + case 'url': + case 'wiki': + case 'workshop': + $name = $data->$title; + $module = $DB->get_record($title, ['name' => $name]); + $cm = get_coursemodule_from_instance($title, $module->id); + $pageurl = "/mod/$title/view.php?id={$cm->id}"; + break; + + default: + throw new DriverException('Invalid custom link title - ' . $title); + } + + $this->open_moodleapp_custom_url($pageurl); + } + + /** + * Closes a popup by clicking on the 'backdrop' behind it. + * + * @When I close the popup in the app + * @throws DriverException If there isn't a popup to close + */ + public function i_close_the_popup_in_the_app() { + $this->spin(function() { + $result = $this->evaluate_script("return window.behat.closePopup();"); + + if ($result !== 'OK') { + throw new DriverException('Error closing popup - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + + /** + * Override app config. + * + * @Given /^the app has the following config:$/ + * @param TableNode $data + */ + public function the_app_has_the_following_config(TableNode $data) { + foreach ($data->getRows() as $configrow) { + $this->appconfig[$configrow[0]] = json_decode($configrow[1]); + } + } + + /** + * Clicks on / touches something that is visible in the app. + * + * Note it is difficult to use the standard 'click on' or 'press' steps because those do not + * distinguish visible items and the app always has many non-visible items in the DOM. + * + * @Then /^I press (".+") in the app$/ + * @param string $locator Element locator + * @throws DriverException If the press doesn't work + */ + public function i_press_in_the_app(string $locator) { + $locator = $this->parse_element_locator($locator); + + $this->spin(function() use ($locator) { + $result = $this->evaluate_script("return window.behat.press($locator);"); + + if ($result !== 'OK') { + throw new DriverException('Error pressing item - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + + /** + * Select an item from a list of options, such as a radio button. + * + * It may be necessary to use this step instead of "I press..." because radio buttons in Ionic are initialized + * with JavaScript, and clicks may not work until they are initialized properly which may cause flaky tests due + * to race conditions. + * + * @Then /^I (unselect|select) (".+") in the app$/ + * @param string $selectedtext + * @param string $locator + * @throws DriverException If the press doesn't work + */ + public function i_select_in_the_app(string $selectedtext, string $locator) { + $selected = $selectedtext === 'select' ? 'YES' : 'NO'; + $locator = $this->parse_element_locator($locator); + + $this->spin(function() use ($selectedtext, $selected, $locator) { + // Don't do anything if the item is already in the expected state. + $result = $this->evaluate_script("return window.behat.isSelected($locator);"); + + if ($result === $selected) { + return true; + } + + // Press item. + $result = $this->evaluate_script("return window.behat.press($locator);"); + + if ($result !== 'OK') { + throw new DriverException('Error pressing item - ' . $result); + } + + // Check that it worked as expected. + $this->wait_for_pending_js(); + + $result = $this->evaluate_script("return window.behat.isSelected($locator);"); + + switch ($result) { + case 'YES': + case 'NO': + if ($result !== $selected) { + throw new ExpectationException("Item wasn't $selectedtext after pressing it", $this->getSession()->getDriver()); + } + + return true; + default: + throw new DriverException('Error finding item - ' . $result); + } + }); + + $this->wait_for_pending_js(); + } + + /** + * Sets a field to the given text value in the app. + * + * Currently this only works for input fields which must be identified using a partial or + * exact match on the placeholder text. + * + * @Given /^I set the field "((?:[^"]|\\")+)" to "((?:[^"]|\\")*)" in the app$/ + * @param string $field Text identifying field + * @param string $value Value for field + * @throws DriverException If the field set doesn't work + */ + public function i_set_the_field_in_the_app(string $field, string $value) { + $field = addslashes_js($field); + $value = addslashes_js($value); + + $this->spin(function() use ($field, $value) { + $result = $this->evaluate_script("return window.behat.setField(\"$field\", \"$value\");"); + + if ($result !== 'OK') { + throw new DriverException('Error setting field - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + + /** + * Checks that the current header stripe in the app contains the expected text. + * + * This can be used to see if the app went to the expected page. + * + * @Then /^the header should be "((?:[^"]|\\")+)" in the app$/ + * @param string $text Expected header text + * @throws DriverException If the header can't be retrieved + * @throws ExpectationException If the header text is different to the expected value + */ + public function the_header_should_be_in_the_app(string $text) { + $this->spin(function() use ($text) { + $result = $this->evaluate_script('return window.behat.getHeader();'); + + if (substr($result, 0, 3) !== 'OK:') { + throw new DriverException('Error getting header - ' . $result); + } + + $header = substr($result, 3); + if (trim($header) !== trim($text)) { + throw new ExpectationException( + "The header text was not as expected: '$header'", + $this->getSession()->getDriver() + ); + } + + return true; + }); + } + + /** + * Check that the app opened a new browser tab. + * + * @Then /^the app should( not)? have opened a browser tab(?: with url "(?P[^"]+)")?$/ + * @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..0a72f2f25 --- /dev/null +++ b/local-moodleappbehat/tests/behat/behat_app_helper.php @@ -0,0 +1,698 @@ +. + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. + +require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); + +use Behat\Mink\Exception\DriverException; + +/** + * Behat app listener. + */ +interface behat_app_listener { + + /** + * Called when the app is loaded. + */ + function on_app_load(): void; + + /** + * Called before the app is unloaded. + */ + function on_app_unload(): void; + +} + +/** + * A trait containing functionality used by the behat app context. + * + * @package core + * @category test + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_app_helper extends behat_base { + + /** @var stdClass Object with data about launched Ionic instance (if any) */ + protected static $ionicrunning = null; + + /** @var array */ + protected static $listeners = []; + + /** @var bool Whether the app is running or not */ + protected $apprunning = false; + + /** @var string */ + protected $lmsversion = null; + + /** + * Register listener. + * + * @param behat_app_listener $listener Listener. + * @return Closure Unregister function. + */ + public static function listen(behat_app_listener $listener): Closure { + self::$listeners[] = $listener; + + return function () use ($listener) { + $index = array_search($listener, self::$listeners); + + if ($index !== false) { + array_splice(self::$listeners, $index, 1); + } + }; + } + + /** + * Checks if the current OS is Windows, from the point of view of task-executing-and-killing. + * + * @return bool True if Windows + */ + protected static function is_windows() : bool { + return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + } + + /** + * Called from behat_hooks when a new scenario starts, if it has the app tag. + * + * This updates Moodle configuration and starts Ionic running, if it isn't already. + */ + public function start_scenario() { + $this->skip_restricted_tags_scenarios(); + $this->check_behat_setup(); + $this->fix_moodle_setup(); + $this->ionicurl = $this->start_or_reuse_ionic(); + } + + /** + * Checks the Behat setup - tags and configuration. + * + * @throws DriverException + */ + protected function check_behat_setup() { + global $CFG; + + // Check JavaScript is enabled. + if (!$this->running_javascript()) { + throw new DriverException('The app requires JavaScript.'); + } + + // Check the config settings are defined. + if (empty($CFG->behat_ionic_wwwroot) && empty($CFG->behat_ionic_dirroot)) { + throw new DriverException('$CFG->behat_ionic_wwwroot or $CFG->behat_ionic_dirroot must be defined.'); + } + } + + /** + * Fixes the Moodle admin settings to allow Moodle App use (if not already correct). + * + * @throws dml_exception If there is any problem changing Moodle settings + */ + protected function fix_moodle_setup() { + global $CFG, $DB; + + // Configure Moodle settings to enable app web services. + if (!$CFG->enablewebservices) { + set_config('enablewebservices', 1); + } + if (!$CFG->enablemobilewebservice) { + set_config('enablemobilewebservice', 1); + } + + // Add 'Create token' and 'Use REST webservice' permissions to authenticated user role. + $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']); + $systemcontext = \context_system::instance(); + role_change_permission($userroleid, $systemcontext, 'moodle/webservice:createtoken', CAP_ALLOW); + role_change_permission($userroleid, $systemcontext, 'webservice/rest:use', CAP_ALLOW); + + // Check the value of the 'webserviceprotocols' config option. Due to weird behaviour + // in Behat with regard to config variables that aren't defined in a settings.php, the + // value in $CFG here may reflect a previous run, so get it direct from the database + // instead. + $field = $DB->get_field('config', 'value', ['name' => 'webserviceprotocols'], IGNORE_MISSING); + if (empty($field)) { + $protocols = []; + } else { + $protocols = explode(',', $field); + } + if (!in_array('rest', $protocols)) { + $protocols[] = 'rest'; + set_config('webserviceprotocols', implode(',', $protocols)); + } + + // Enable mobile service. + require_once($CFG->dirroot . '/webservice/lib.php'); + $webservicemanager = new webservice(); + $service = $webservicemanager->get_external_service_by_shortname(MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST); + + if (!$service->enabled) { + $service->enabled = 1; + $webservicemanager->update_external_service($service); + } + } + + /** + * Starts an Ionic server if necessary, or uses an existing one. + * + * @return string URL to Ionic server + * @throws DriverException If there's a system error starting Ionic + */ + protected function start_or_reuse_ionic() { + global $CFG; + + if (empty($CFG->behat_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) { + // Use supplied Ionic server which should already be running. + $url = $CFG->behat_ionic_wwwroot; + } else if (self::$ionicrunning) { + // Use existing Ionic instance launched previously. + $url = self::$ionicrunning->url; + } else { + // Open Ionic process in relevant path. + $path = realpath($CFG->behat_ionic_dirroot); + $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log'; + $prefix = ''; + // Except on Windows, use 'exec' so that we get the pid of the actual Node process + // and not the shell it uses to execute. You can't do exec on Windows; there is a + // bypass_shell option but it is not the same thing and isn't usable here. + if (!self::is_windows()) { + $prefix = 'exec '; + } + $process = proc_open($prefix . 'ionic serve --no-interactive --no-open', + [['pipe', 'r'], ['pipe', 'w'], ['file', $stderrfile, 'w']], $pipes, $path); + if ($process === false) { + throw new DriverException('Error starting Ionic process'); + } + fclose($pipes[0]); + + // Get pid - we will need this to kill the process. + $status = proc_get_status($process); + $pid = $status['pid']; + + // Read data from stdout until the server comes online. + // Note: On Windows it is impossible to read simultaneously from stderr and stdout + // because stream_select and non-blocking I/O don't work on process pipes, so that is + // why stderr was redirected to a file instead. Also, this code is simpler. + $url = null; + $stdoutlog = ''; + while (true) { + $line = fgets($pipes[1], 4096); + if ($line === false) { + break; + } + + $stdoutlog .= $line; + + if (preg_match('~^\s*Local: (http\S*)~', $line, $matches)) { + $url = $matches[1]; + break; + } + } + + // If it failed, close the pipes and the process. + if (!$url) { + fclose($pipes[1]); + proc_close($process); + $logpath = $CFG->dataroot . '/behat/ionic-start.log'; + $stderrlog = file_get_contents($stderrfile); + @unlink($stderrfile); + file_put_contents($logpath, + "Ionic startup log from " . date('c') . + "\n\n----STDOUT----\n$stdoutlog\n\n----STDERR----\n$stderrlog"); + throw new DriverException('Unable to start Ionic. See ' . $logpath); + } + + // Remember the URL, so we can reuse it next time, and other details so we can kill + // the process. + self::$ionicrunning = (object)['url' => $url, 'process' => $process, 'pipes' => $pipes, + 'pid' => $pid]; + $url = self::$ionicrunning->url; + } + return $url; + } + + /** + * Closes Ionic (if it was started) at end of test suite. + * + * @AfterSuite + */ + public static function close_ionic() { + if (self::$ionicrunning) { + fclose(self::$ionicrunning->pipes[1]); + + if (self::is_windows()) { + // Using proc_terminate here does not work. It terminates the process but not any + // other processes it might have launched. Instead, we need to use an OS-specific + // mechanism to kill the process and children based on its pid. + exec('taskkill /F /T /PID ' . self::$ionicrunning->pid); + } else { + // On Unix this actually works, although only due to the 'exec' command inserted + // above. + proc_terminate(self::$ionicrunning->process); + } + self::$ionicrunning = null; + } + } + + /** + * Goes to the app page and then sets up some initial JavaScript so we can use it. + * + * @param string $url App URL + * @throws DriverException If the app fails to load properly + */ + protected function prepare_browser(array $options = []) { + $restart = $options['restart'] ?? true; + + if ($restart) { + if ($this->apprunning) { + $this->notify_unload(); + } + + // Restart the browser and set its size. + $this->getSession()->restart(); + $this->resize_window($this->windowsize, true); + + if (empty($this->ionicurl)) { + $this->ionicurl = $this->start_or_reuse_ionic(); + } + + // Visit the Ionic URL. + $this->getSession()->visit($this->ionicurl); + $this->notify_load(); + + $this->apprunning = true; + } + + // Wait the application to load. + $this->spin(function($context) { + $title = $context->getSession()->getPage()->find('xpath', '//title'); + + if ($title) { + $text = $title->getHtml(); + + if ($text === 'Moodle App') { + return true; + } + } + + throw new DriverException('Moodle App not found in browser'); + }, false, 60); + + try { + // Init Behat JavaScript runtime. + + $initOptions = new StdClass(); + $initOptions->skipOnBoarding = $options['skiponboarding'] ?? true; + $initOptions->configOverrides = $this->appconfig; + + $this->execute_script('window.behatInit(' . json_encode($initOptions) . ');'); + } catch (Exception $error) { + throw new DriverException('Moodle App not running or not running on Automated mode.'); + } + + if ($restart) { + // Assert initial page. + $this->spin(function($context) { + $page = $context->getSession()->getPage(); + $element = $page->find('xpath', '//page-core-login-site//input[@name="url"]'); + + if ($element) { + // Login screen found. + return true; + } + + if ($page->find('xpath', '//page-core-mainmenu')) { + // Main menu found. + return true; + } + + throw new DriverException('Moodle App not launched properly'); + }, false, 60); + } + + // Continue only after JS finishes. + $this->wait_for_pending_js(); + } + + /** + * Parse an element locator string. + * + * @param string $text Element locator string. + * @return JSON of the locator. + */ + public function parse_element_locator(string $text): string { + preg_match( + '/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: (near|within) "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/', + $text, + $matches + ); + + $locator = [ + 'text' => str_replace('\\"', '"', $matches[1]), + 'selector' => $matches[2] ?? null, + ]; + + if (!empty($matches[3])) { + $locator[$matches[3]] = (object) [ + 'text' => str_replace('\\"', '"', $matches[4]), + 'selector' => $matches[5] ?? null, + ]; + } + + return json_encode((object) $locator); + } + + /** + * Replaces $WWWROOT for the url of the Moodle site. + * + * @Transform /^(.*\$WWWROOT.*)$/ + * @param string $text Text. + * @return string + */ + public function replace_wwwroot($text) { + global $CFG; + + return str_replace('$WWWROOT', $CFG->behat_wwwroot, $text); + } + + /** + * Replace arguments with the format "${activity:field}" from a string, where "activity" is + * the idnumber of an activity and "field" is the activity's field to get replacement from. + * + * At the moment, the only field supported is "cmid", the id of the course module for this activity. + * + * @param string $text Original text. + * @return string Text with arguments replaced. + */ + protected function replace_arguments(string $text): string { + global $DB; + + preg_match_all("/\\$\\{([^:}]+):([^}]+)\\}/", $text, $matches); + + foreach ($matches[0] as $index => $match) { + switch ($matches[2][$index]) { + case 'cmid': + $coursemodule = $DB->get_record('course_modules', ['idnumber' => $matches[1][$index]]); + $text = str_replace($match, $coursemodule->id, $text); + + break; + } + } + + return $text; + } + + /** + * Notify to listeners that the app was just loaded. + */ + protected function notify_load(): void { + foreach (self::$listeners as $listener) { + $listener->on_app_load(); + } + } + + /** + * Notify to listeners that the app is about to be unloaded. + */ + protected function notify_unload(): void { + foreach (self::$listeners as $listener) { + $listener->on_app_unload(); + } + } + + + /** + * Trigger Angular change detection. + */ + protected function trigger_angular_change_detection() { + $this->getSession()->executeScript('ngZone.run(() => {});'); + } + + /** + * Evaluate a script that returns a Promise. + * + * @param string $script + * @return mixed Resolved promise result. + */ + protected function evaluate_async_script(string $script) { + $script = preg_replace('/^return\s+/', '', $script); + $script = preg_replace('/;$/', '', $script); + $start = microtime(true); + $promisevariable = 'PROMISE_RESULT_' . time(); + $timeout = self::get_timeout(); + + $this->evaluate_script("Promise.resolve($script) + .then(result => window.$promisevariable = result) + .catch(error => window.$promisevariable = 'Async code rejected: ' + error?.message);"); + + do { + if (microtime(true) - $start > $timeout) { + throw new DriverException("Async script not resolved after $timeout seconds"); + } + + usleep(100000); + } while (!$this->evaluate_script("return '$promisevariable' in window;")); + + $result = $this->evaluate_script("return window.$promisevariable;"); + + $this->evaluate_script("delete window.$promisevariable;"); + + return $result; + } + + /** + * Opens a custom URL for automatic login and redirect from the Moodle App (and waits to finish.) + * + * @param string $username Of the user that needs to be logged in. + * @param string $path To redirect the user. + * @param string $successXPath If a path is declared, the XPath of the element to lookat after redirect. + */ + protected function open_moodleapp_custom_login_url($username, $path = '', string $successXPath = '') { + global $CFG, $DB; + + require_once($CFG->libdir.'/externallib.php'); + require_once($CFG->libdir.'/moodlelib.php'); + + // Ensure the user exists. + $userid = $DB->get_field('user', 'id', [ 'username' => $username ]); + if (!$userid) { + throw new DriverException("User '$username' not found"); + } + + // Get or create the user token. + $service = $DB->get_record('external_services', ['shortname' => 'moodle_mobile_app']); + + $token_params = [ + 'userid' => $userid, + 'externalserviceid' => $service->id, + ]; + $usertoken = $DB->get_record('external_tokens', $token_params); + if (!$usertoken) { + $context = context_system::instance(); + $token = external_generate_token(EXTERNAL_TOKEN_PERMANENT, $service, $userid, $context); + $token_params['token'] = $token; + $privatetoken = $DB->get_field('external_tokens', 'privatetoken', $token_params); + } else { + $token = $usertoken->token; + $privatetoken = $usertoken->privatetoken; + } + + // Generate custom URL. + $parsed_url = parse_url($CFG->behat_wwwroot); + $domain = $parsed_url['host']; + $url = $this->get_mobile_url_scheme() . "://$username@$domain?token=$token&privatetoken=$privatetoken"; + + if (!empty($path)) { + $url .= '&redirect='.urlencode($CFG->behat_wwwroot.$path); + } else { + $successXPath = '//page-core-mainmenu'; + } + + $this->handle_url_and_wait_page_to_load($url, $successXPath); + } + + /** + * Opens a custom URL on the Moodle App (and waits to finish.) + * + * @param string $path To navigate. + * @param string $successXPath The XPath of the element to lookat after navigation. + */ + protected function open_moodleapp_custom_url(string $path, string $successXPath = '') { + global $CFG; + + $urlscheme = $this->get_mobile_url_scheme(); + $url = "$urlscheme://link=" . urlencode($CFG->behat_wwwroot.$path); + + $this->handle_url_and_wait_page_to_load($url); + } + + /** + * Handles the custom URL on the Moodle App (and waits to finish.) + * + * @param string $customurl To navigate. + * @param string $successXPath The XPath of the element to lookat after navigation. + */ + protected function handle_url_and_wait_page_to_load(string $customurl, string $successXPath = '') { + // Instead of using evaluate_async_script, we wait for the path to load. + $this->evaluate_script("return window.behat.handleCustomURL('$customurl')"); + + $this->wait_for_pending_js(); + + if (!empty($successXPath)) { + // Wait until the page appears. + $this->spin( + function($context, $args) use ($successXPath) { + $found = $context->getSession()->getPage()->find('xpath', $successXPath); + if ($found) { + return true; + } + throw new DriverException('Moodle App custom URL page not loaded'); + }, false, 30); + + // Wait for JS to finish as well. + $this->wait_for_pending_js(); + } + } + + /** + * Returns the current mobile url scheme of the site. + */ + protected function get_mobile_url_scheme() { + $mobilesettings = get_config('tool_mobile'); + + return !empty($mobilesettings->forcedurlscheme) ? $mobilesettings->forcedurlscheme : 'moodlemobile'; + } + + /** + * Get a coursemodule from an activity name or idnumber with course. + * + * @param string $activity + * @param string $identifier + * @param string $coursename + * @return cm_info + */ + protected function get_cm_by_activity_name_and_course(string $activity, string $identifier, string $coursename): cm_info { + global $DB; + + $courseid = $this->get_course_id($coursename); + if (!$courseid) { + throw new DriverException("Course '$coursename' not found"); + } + + if ($activity === 'assignment') { + $activity = 'assign'; + } + + $cmtable = new \core\dml\table('course_modules', 'cm', 'cm'); + $cmfrom = $cmtable->get_from_sql(); + + $acttable = new \core\dml\table($activity, 'a', 'a'); + $actselect = $acttable->get_field_select(); + $actfrom = $acttable->get_from_sql(); + + $sql = <<get_record_sql($sql, [ + 'modname' => $activity, + 'idnumber' => $identifier, + 'name' => $identifier, + 'courseid' => $courseid, + ], MUST_EXIST); + + return get_fast_modinfo($courseid)->get_cm($result->cmid); + } + + /** + * Workaround while MDL-74621 is not integrated in all supported versions. + * This function will skip scenarios based on @lms_from and @lms_upto tags. + */ + public function skip_restricted_tags_scenarios() { + if (is_null($this->lmsversion)) { + global $CFG; + + $version = trim($CFG->release); + $versionarr = explode(" ", $version); + if (!empty($versionarr)) { + $version = $versionarr[0]; + } + + // Replace everything but numbers and dots by dots. + $version = preg_replace('/[^\.\d]/', '.', $version); + // Combine multiple dots in one. + $version = preg_replace('/(\.{2,})/', '.', $version); + // Trim possible leading and trailing dots. + $this->lmsversion = trim($version, '.'); + } + + if ($this->has_version_restrictions()) { + // Skip this test. + throw new DriverException('Incompatible tags.'); + } + } + + /** + * Gets if version is incompatible with the @lms_from and @lms_upto tags. + * + * @return bool If scenario has any version incompatible tag. + */ + protected function has_version_restrictions() : bool { + $usedtags = behat_hooks::get_tags_for_scenario(); + + $detectedversioncount = substr_count($this->lmsversion, '.'); + + // Set up relevant tags for each version. + $usedtags = array_keys($usedtags); + foreach ($usedtags as $usedtag) { + if (!preg_match('~^lms_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) { + // No match, ignore. + continue; + } + + $direction = $matches[1]; + $version = $matches[2]; + + $versioncount = substr_count($version, '.'); + + // Compare versions on same length. + $detected = $this->lmsversion; + if ($versioncount < $detectedversioncount) { + $detected_parts = explode('.', $this->lmsversion); + array_splice($detected_parts, $versioncount - $detectedversioncount); + $detected = implode('.', $detected_parts); + } + + $compare = version_compare($detected, $version); + // Installed version OLDER than the one being considered, so do not + // include any scenarios that only run from the considered version up. + if ($compare === -1 && $direction === 'from') { + return true; + } + // Installed version NEWER than the one being considered, so do not + // include any scenarios that only run up to that version. + if ($compare === 1 && $direction === 'upto') { + return true; + } + } + + return false; + } +} 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/moodle.config.json b/moodle.config.json index a5f1ca6b6..17f6627ee 100644 --- a/moodle.config.json +++ b/moodle.config.json @@ -1,8 +1,8 @@ { "app_id": "com.moodle.moodlemobile", "appname": "Moodle Mobile", - "versioncode": 40100, - "versionname": "4.0.1", + "versioncode": 41000, + "versionname": "4.1.0-dev", "cache_update_frequency_usually": 420000, "cache_update_frequency_often": 1200000, "cache_update_frequency_sometimes": 3600000, @@ -76,9 +76,10 @@ "password": "moodle" } }, + "defaultZoomLevel": "none", "zoomlevels": { - "normal": 100, - "low": 110, + "none": 100, + "medium": 110, "high": 120 }, "customurlscheme": "moodlemobile", diff --git a/package-lock.json b/package-lock.json index d0d4cd5c6..a65818922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,48 +1,48 @@ { "name": "moodlemobile", - "version": "4.0.1", + "version": "4.1.0-dev", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "moodlemobile", - "version": "4.0.1", + "version": "4.1.0-dev", "license": "Apache-2.0", "dependencies": { - "@angular/animations": "10.0.14", - "@angular/common": "10.0.14", - "@angular/core": "10.0.14", - "@angular/forms": "10.0.14", - "@angular/platform-browser": "10.0.14", - "@angular/platform-browser-dynamic": "10.0.14", - "@angular/router": "10.0.14", - "@ionic-native/badge": "5.33.0", - "@ionic-native/camera": "5.33.0", - "@ionic-native/chooser": "5.33.0", - "@ionic-native/clipboard": "5.33.0", - "@ionic-native/core": "5.33.0", - "@ionic-native/device": "5.33.0", - "@ionic-native/diagnostic": "5.33.0", - "@ionic-native/file": "5.33.0", - "@ionic-native/file-opener": "5.33.0", - "@ionic-native/file-transfer": "5.33.0", - "@ionic-native/geolocation": "5.33.0", - "@ionic-native/http": "5.33.0", - "@ionic-native/in-app-browser": "5.33.0", - "@ionic-native/ionic-webview": "5.33.0", - "@ionic-native/keyboard": "5.33.0", - "@ionic-native/local-notifications": "5.33.0", - "@ionic-native/media": "5.33.0", - "@ionic-native/media-capture": "5.33.0", - "@ionic-native/network": "5.33.0", - "@ionic-native/push": "5.33.0", - "@ionic-native/qr-scanner": "5.33.0", - "@ionic-native/splash-screen": "5.33.0", - "@ionic-native/sqlite": "5.33.0", - "@ionic-native/status-bar": "5.33.0", - "@ionic-native/web-intent": "5.33.0", - "@ionic-native/zip": "5.33.0", - "@ionic/angular": "5.9.2", + "@angular/animations": "~10.0.14", + "@angular/common": "~10.0.14", + "@angular/core": "~10.0.14", + "@angular/forms": "~10.0.14", + "@angular/platform-browser": "~10.0.14", + "@angular/platform-browser-dynamic": "~10.0.14", + "@angular/router": "~10.0.14", + "@ionic-native/badge": "^5.33.0", + "@ionic-native/camera": "^5.33.0", + "@ionic-native/chooser": "^5.33.0", + "@ionic-native/clipboard": "^5.33.0", + "@ionic-native/core": "^5.33.0", + "@ionic-native/device": "^5.33.0", + "@ionic-native/diagnostic": "^5.33.0", + "@ionic-native/file": "^5.33.0", + "@ionic-native/file-opener": "^5.33.0", + "@ionic-native/file-transfer": "^5.33.0", + "@ionic-native/geolocation": "^5.33.0", + "@ionic-native/http": "^5.33.0", + "@ionic-native/in-app-browser": "^5.33.0", + "@ionic-native/ionic-webview": "^5.33.0", + "@ionic-native/keyboard": "^5.33.0", + "@ionic-native/local-notifications": "^5.33.0", + "@ionic-native/media": "^5.33.0", + "@ionic-native/media-capture": "^5.33.0", + "@ionic-native/network": "^5.33.0", + "@ionic-native/push": "^5.33.0", + "@ionic-native/qr-scanner": "^5.33.0", + "@ionic-native/splash-screen": "^5.33.0", + "@ionic-native/sqlite": "^5.33.0", + "@ionic-native/status-bar": "^5.33.0", + "@ionic-native/web-intent": "^5.33.0", + "@ionic-native/zip": "^5.33.0", + "@ionic/angular": "^5.9.2", "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", "@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1", @@ -50,106 +50,106 @@ "@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2", "@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1", "@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4", - "@ngx-translate/core": "13.0.0", - "@ngx-translate/http-loader": "6.0.0", - "@types/chart.js": "2.9.31", + "@ngx-translate/core": "^13.0.0", + "@ngx-translate/http-loader": "^6.0.0", + "@types/chart.js": "^2.9.31", "@types/cordova": "0.0.34", - "@types/dom-mediacapture-record": "1.0.7", - "chart.js": "2.9.4", - "com-darryncampbell-cordova-plugin-intent": "2.2.0", - "cordova": "11.0.0", - "cordova-android": "10.1.1", - "cordova-clipboard": "1.3.0", - "cordova-ios": "6.2.0", - "cordova-plugin-add-swift-support": "2.0.2", - "cordova-plugin-advanced-http": "3.2.2", - "cordova-plugin-badge": "0.8.8", - "cordova-plugin-camera": "6.0.0", - "cordova-plugin-chooser": "1.3.2", - "cordova-plugin-customurlscheme": "5.0.2", - "cordova-plugin-device": "2.0.3", - "cordova-plugin-file": "6.0.2", - "cordova-plugin-file-opener2": "3.0.5", - "cordova-plugin-geolocation": "4.1.0", - "cordova-plugin-ionic-keyboard": "2.2.0", - "cordova-plugin-media": "5.0.4", - "cordova-plugin-media-capture": "3.0.3", - "cordova-plugin-network-information": "3.0.0", - "cordova-plugin-prevent-override": "1.0.1", - "cordova-plugin-splashscreen": "6.0.0", - "cordova-plugin-statusbar": "3.0.0", - "cordova-plugin-wkuserscript": "1.0.1", - "cordova-plugin-wkwebview-cookies": "1.0.1", - "cordova-sqlite-storage": "6.0.0", - "cordova.plugins.diagnostic": "6.1.1", - "core-js": "3.9.1", - "es6-promise-plugin": "4.2.2", - "hammerjs": "2.0.8", - "jszip": "3.7.1", + "@types/dom-mediacapture-record": "^1.0.7", + "chart.js": "^2.9.4", + "com-darryncampbell-cordova-plugin-intent": "^2.2.0", + "cordova": "^11.0.0", + "cordova-android": "^10.1.1", + "cordova-clipboard": "^1.3.0", + "cordova-ios": "^6.2.0", + "cordova-plugin-add-swift-support": "^2.0.2", + "cordova-plugin-advanced-http": "^3.2.2", + "cordova-plugin-badge": "^0.8.8", + "cordova-plugin-camera": "^6.0.0", + "cordova-plugin-chooser": "^1.3.2", + "cordova-plugin-customurlscheme": "^5.0.2", + "cordova-plugin-device": "^2.0.3", + "cordova-plugin-file": "^6.0.2", + "cordova-plugin-file-opener2": "^3.0.5", + "cordova-plugin-geolocation": "^4.1.0", + "cordova-plugin-ionic-keyboard": "^2.2.0", + "cordova-plugin-media": "^5.0.4", + "cordova-plugin-media-capture": "^3.0.3", + "cordova-plugin-network-information": "^3.0.0", + "cordova-plugin-prevent-override": "^1.0.1", + "cordova-plugin-splashscreen": "^6.0.0", + "cordova-plugin-statusbar": "^3.0.0", + "cordova-plugin-wkuserscript": "^1.0.1", + "cordova-plugin-wkwebview-cookies": "^1.0.1", + "cordova-sqlite-storage": "^6.0.0", + "cordova.plugins.diagnostic": "^6.1.1", + "core-js": "^3.9.1", + "es6-promise-plugin": "^4.2.2", + "hammerjs": "^2.0.8", + "jszip": "^3.7.1", "mathjax": "2.7.7", - "moment": "2.29.2", - "nl.kingsquare.cordova.background-audio": "1.0.1", - "rxjs": "6.5.5", - "ts-md5": "1.2.7", - "tslib": "2.3.1", - "zone.js": "0.10.3" + "moment": "^2.29.2", + "nl.kingsquare.cordova.background-audio": "^1.0.1", + "rxjs": "~6.5.5", + "ts-md5": "^1.2.7", + "tslib": "^2.3.1", + "zone.js": "~0.10.3" }, "devDependencies": { - "@angular-builders/custom-webpack": "10.0.1", - "@angular-devkit/architect": "0.1202.7", - "@angular-devkit/build-angular": "0.1000.8", - "@angular-eslint/builder": "4.2.0", - "@angular-eslint/eslint-plugin": "4.2.0", - "@angular-eslint/eslint-plugin-template": "4.2.0", - "@angular-eslint/schematics": "4.2.0", - "@angular-eslint/template-parser": "4.2.0", - "@angular/cli": "10.0.8", - "@angular/compiler": "10.0.14", - "@angular/compiler-cli": "10.0.14", - "@angular/language-service": "10.0.14", - "@ionic/angular-toolkit": "2.3.3", - "@ionic/cli": "6.19.0", - "@types/faker": "5.1.3", - "@types/node": "12.12.64", - "@types/resize-observer-browser": "0.1.5", - "@types/webpack-env": "1.16.0", - "@typescript-eslint/eslint-plugin": "4.22.0", - "@typescript-eslint/parser": "4.22.0", - "check-es-compat": "1.1.1", - "cordova-plugin-androidx-adapter": "1.1.3", + "@angular-builders/custom-webpack": "^10.0.1", + "@angular-devkit/architect": "^0.1202.7", + "@angular-devkit/build-angular": "~0.1000.8", + "@angular-eslint/builder": "^4.2.0", + "@angular-eslint/eslint-plugin": "^4.2.0", + "@angular-eslint/eslint-plugin-template": "^4.2.0", + "@angular-eslint/schematics": "^4.2.0", + "@angular-eslint/template-parser": "^4.2.0", + "@angular/cli": "~10.0.8", + "@angular/compiler": "~10.0.14", + "@angular/compiler-cli": "~10.0.14", + "@angular/language-service": "~10.0.14", + "@ionic/angular-toolkit": "^2.3.3", + "@ionic/cli": "^6.19.0", + "@types/faker": "^5.1.3", + "@types/node": "^12.12.64", + "@types/resize-observer-browser": "^0.1.5", + "@types/webpack-env": "^1.16.0", + "@typescript-eslint/eslint-plugin": "^4.22.0", + "@typescript-eslint/parser": "^4.22.0", + "check-es-compat": "^1.1.1", + "cordova-plugin-androidx-adapter": "^1.1.3", "cordova-plugin-screen-orientation": "^3.0.2", - "cross-env": "7.0.3", - "eslint": "7.25.0", - "eslint-config-prettier": "8.3.0", - "eslint-plugin-header": "3.1.1", - "eslint-plugin-import": "2.22.1", - "eslint-plugin-jest": "24.3.6", - "eslint-plugin-jsdoc": "32.3.3", - "eslint-plugin-prefer-arrow": "1.2.3", - "eslint-plugin-promise": "5.1.0", - "faker": "5.1.0", - "fs-extra": "9.1.0", + "cross-env": "^7.0.3", + "eslint": "^7.25.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jest": "^24.3.6", + "eslint-plugin-jsdoc": "^32.3.3", + "eslint-plugin-prefer-arrow": "^1.2.3", + "eslint-plugin-promise": "^5.1.0", + "faker": "^5.1.0", + "fs-extra": "^9.1.0", "gulp": "4.0.2", - "gulp-clip-empty-files": "0.1.2", - "gulp-concat": "2.6.1", - "gulp-flatten": "0.4.0", - "gulp-htmlmin": "5.0.1", - "gulp-rename": "2.0.0", - "gulp-slash": "1.1.3", - "jest": "26.5.2", - "jest-preset-angular": "8.3.1", - "jsonc-parser": "2.3.1", - "native-run": "1.4.0", - "terser-webpack-plugin": "4.2.3", - "ts-jest": "26.4.1", - "ts-node": "8.3.0", - "typescript": "3.9.9" + "gulp-clip-empty-files": "^0.1.2", + "gulp-concat": "^2.6.1", + "gulp-flatten": "^0.4.0", + "gulp-htmlmin": "^5.0.1", + "gulp-rename": "^2.0.0", + "gulp-slash": "^1.1.3", + "jest": "^26.5.2", + "jest-preset-angular": "^8.3.1", + "jsonc-parser": "^2.3.1", + "native-run": "^1.4.0", + "terser-webpack-plugin": "^4.2.3", + "ts-jest": "^26.4.1", + "ts-node": "~8.3.0", + "typescript": "^3.9.9" }, "engines": { "node": ">=14.15.0 <15" }, "optionalDependencies": { - "keytar": "7.2.0" + "keytar": "^7.2.0" } }, "node_modules/@angular-builders/custom-webpack": { diff --git a/package.json b/package.json index 87364341d..5470b37ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "4.0.1", + "version": "4.1.0-dev", "description": "The official app for Moodle.", "author": { "name": "Moodle Pty Ltd.", @@ -38,40 +38,40 @@ "ionic:build:before": "gulp" }, "dependencies": { - "@angular/animations": "10.0.14", - "@angular/common": "10.0.14", - "@angular/core": "10.0.14", - "@angular/forms": "10.0.14", - "@angular/platform-browser": "10.0.14", - "@angular/platform-browser-dynamic": "10.0.14", - "@angular/router": "10.0.14", - "@ionic-native/badge": "5.33.0", - "@ionic-native/camera": "5.33.0", - "@ionic-native/chooser": "5.33.0", - "@ionic-native/clipboard": "5.33.0", - "@ionic-native/core": "5.33.0", - "@ionic-native/device": "5.33.0", - "@ionic-native/diagnostic": "5.33.0", - "@ionic-native/file": "5.33.0", - "@ionic-native/file-opener": "5.33.0", - "@ionic-native/file-transfer": "5.33.0", - "@ionic-native/geolocation": "5.33.0", - "@ionic-native/http": "5.33.0", - "@ionic-native/in-app-browser": "5.33.0", - "@ionic-native/ionic-webview": "5.33.0", - "@ionic-native/keyboard": "5.33.0", - "@ionic-native/local-notifications": "5.33.0", - "@ionic-native/media": "5.33.0", - "@ionic-native/media-capture": "5.33.0", - "@ionic-native/network": "5.33.0", - "@ionic-native/push": "5.33.0", - "@ionic-native/qr-scanner": "5.33.0", - "@ionic-native/splash-screen": "5.33.0", - "@ionic-native/sqlite": "5.33.0", - "@ionic-native/status-bar": "5.33.0", - "@ionic-native/web-intent": "5.33.0", - "@ionic-native/zip": "5.33.0", - "@ionic/angular": "5.9.2", + "@angular/animations": "~10.0.14", + "@angular/common": "~10.0.14", + "@angular/core": "~10.0.14", + "@angular/forms": "~10.0.14", + "@angular/platform-browser": "~10.0.14", + "@angular/platform-browser-dynamic": "~10.0.14", + "@angular/router": "~10.0.14", + "@ionic-native/badge": "^5.33.0", + "@ionic-native/camera": "^5.33.0", + "@ionic-native/chooser": "^5.33.0", + "@ionic-native/clipboard": "^5.33.0", + "@ionic-native/core": "^5.33.0", + "@ionic-native/device": "^5.33.0", + "@ionic-native/diagnostic": "^5.33.0", + "@ionic-native/file": "^5.33.0", + "@ionic-native/file-opener": "^5.33.0", + "@ionic-native/file-transfer": "^5.33.0", + "@ionic-native/geolocation": "^5.33.0", + "@ionic-native/http": "^5.33.0", + "@ionic-native/in-app-browser": "^5.33.0", + "@ionic-native/ionic-webview": "^5.33.0", + "@ionic-native/keyboard": "^5.33.0", + "@ionic-native/local-notifications": "^5.33.0", + "@ionic-native/media": "^5.33.0", + "@ionic-native/media-capture": "^5.33.0", + "@ionic-native/network": "^5.33.0", + "@ionic-native/push": "^5.33.0", + "@ionic-native/qr-scanner": "^5.33.0", + "@ionic-native/splash-screen": "^5.33.0", + "@ionic-native/sqlite": "^5.33.0", + "@ionic-native/status-bar": "^5.33.0", + "@ionic-native/web-intent": "^5.33.0", + "@ionic-native/zip": "^5.33.0", + "@ionic/angular": "^5.9.2", "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", "@moodlehq/cordova-plugin-ionic-webview": "5.0.0-moodle.1", @@ -79,100 +79,100 @@ "@moodlehq/cordova-plugin-qrscanner": "3.0.1-moodle.2", "@moodlehq/cordova-plugin-zip": "3.1.0-moodle.1", "@moodlehq/phonegap-plugin-push": "2.0.0-moodle.4", - "@ngx-translate/core": "13.0.0", - "@ngx-translate/http-loader": "6.0.0", - "@types/chart.js": "2.9.31", + "@ngx-translate/core": "^13.0.0", + "@ngx-translate/http-loader": "^6.0.0", + "@types/chart.js": "^2.9.31", "@types/cordova": "0.0.34", - "@types/dom-mediacapture-record": "1.0.7", - "chart.js": "2.9.4", - "com-darryncampbell-cordova-plugin-intent": "2.2.0", - "cordova": "11.0.0", - "cordova-android": "10.1.1", - "cordova-clipboard": "1.3.0", - "cordova-ios": "6.2.0", - "cordova-plugin-add-swift-support": "2.0.2", - "cordova-plugin-advanced-http": "3.2.2", - "cordova-plugin-badge": "0.8.8", - "cordova-plugin-camera": "6.0.0", - "cordova-plugin-chooser": "1.3.2", - "cordova-plugin-customurlscheme": "5.0.2", - "cordova-plugin-device": "2.0.3", - "cordova-plugin-file": "6.0.2", - "cordova-plugin-file-opener2": "3.0.5", - "cordova-plugin-geolocation": "4.1.0", - "cordova-plugin-ionic-keyboard": "2.2.0", - "cordova-plugin-media": "5.0.4", - "cordova-plugin-media-capture": "3.0.3", - "cordova-plugin-network-information": "3.0.0", - "cordova-plugin-prevent-override": "1.0.1", - "cordova-plugin-splashscreen": "6.0.0", - "cordova-plugin-statusbar": "3.0.0", - "cordova-plugin-wkuserscript": "1.0.1", - "cordova-plugin-wkwebview-cookies": "1.0.1", - "cordova-sqlite-storage": "6.0.0", - "cordova.plugins.diagnostic": "6.1.1", - "core-js": "3.9.1", - "es6-promise-plugin": "4.2.2", - "hammerjs": "2.0.8", - "jszip": "3.7.1", + "@types/dom-mediacapture-record": "^1.0.7", + "chart.js": "^2.9.4", + "com-darryncampbell-cordova-plugin-intent": "^2.2.0", + "cordova": "^11.0.0", + "cordova-android": "^10.1.1", + "cordova-clipboard": "^1.3.0", + "cordova-ios": "^6.2.0", + "cordova-plugin-add-swift-support": "^2.0.2", + "cordova-plugin-advanced-http": "^3.2.2", + "cordova-plugin-badge": "^0.8.8", + "cordova-plugin-camera": "^6.0.0", + "cordova-plugin-chooser": "^1.3.2", + "cordova-plugin-customurlscheme": "^5.0.2", + "cordova-plugin-device": "^2.0.3", + "cordova-plugin-file": "^6.0.2", + "cordova-plugin-file-opener2": "^3.0.5", + "cordova-plugin-geolocation": "^4.1.0", + "cordova-plugin-ionic-keyboard": "^2.2.0", + "cordova-plugin-media": "^5.0.4", + "cordova-plugin-media-capture": "^3.0.3", + "cordova-plugin-network-information": "^3.0.0", + "cordova-plugin-prevent-override": "^1.0.1", + "cordova-plugin-splashscreen": "^6.0.0", + "cordova-plugin-statusbar": "^3.0.0", + "cordova-plugin-wkuserscript": "^1.0.1", + "cordova-plugin-wkwebview-cookies": "^1.0.1", + "cordova-sqlite-storage": "^6.0.0", + "cordova.plugins.diagnostic": "^6.1.1", + "core-js": "^3.9.1", + "es6-promise-plugin": "^4.2.2", + "hammerjs": "^2.0.8", + "jszip": "^3.7.1", "mathjax": "2.7.7", - "moment": "2.29.2", - "nl.kingsquare.cordova.background-audio": "1.0.1", - "rxjs": "6.5.5", - "ts-md5": "1.2.7", - "tslib": "2.3.1", - "zone.js": "0.10.3" + "moment": "^2.29.2", + "nl.kingsquare.cordova.background-audio": "^1.0.1", + "rxjs": "~6.5.5", + "ts-md5": "^1.2.7", + "tslib": "^2.3.1", + "zone.js": "~0.10.3" }, "devDependencies": { - "@angular-builders/custom-webpack": "10.0.1", - "@angular-devkit/architect": "0.1202.7", - "@angular-devkit/build-angular": "0.1000.8", - "@angular-eslint/builder": "4.2.0", - "@angular-eslint/eslint-plugin": "4.2.0", - "@angular-eslint/eslint-plugin-template": "4.2.0", - "@angular-eslint/schematics": "4.2.0", - "@angular-eslint/template-parser": "4.2.0", - "@angular/cli": "10.0.8", - "@angular/compiler": "10.0.14", - "@angular/compiler-cli": "10.0.14", - "@angular/language-service": "10.0.14", - "@ionic/angular-toolkit": "2.3.3", - "@ionic/cli": "6.19.0", - "@types/faker": "5.1.3", - "@types/node": "12.12.64", - "@types/resize-observer-browser": "0.1.5", - "@types/webpack-env": "1.16.0", - "@typescript-eslint/eslint-plugin": "4.22.0", - "@typescript-eslint/parser": "4.22.0", - "check-es-compat": "1.1.1", - "cordova-plugin-androidx-adapter": "1.1.3", + "@angular-builders/custom-webpack": "^10.0.1", + "@angular-devkit/architect": "^0.1202.7", + "@angular-devkit/build-angular": "~0.1000.8", + "@angular-eslint/builder": "^4.2.0", + "@angular-eslint/eslint-plugin": "^4.2.0", + "@angular-eslint/eslint-plugin-template": "^4.2.0", + "@angular-eslint/schematics": "^4.2.0", + "@angular-eslint/template-parser": "^4.2.0", + "@angular/cli": "~10.0.8", + "@angular/compiler": "~10.0.14", + "@angular/compiler-cli": "~10.0.14", + "@angular/language-service": "~10.0.14", + "@ionic/angular-toolkit": "^2.3.3", + "@ionic/cli": "^6.19.0", + "@types/faker": "^5.1.3", + "@types/node": "^12.12.64", + "@types/resize-observer-browser": "^0.1.5", + "@types/webpack-env": "^1.16.0", + "@typescript-eslint/eslint-plugin": "^4.22.0", + "@typescript-eslint/parser": "^4.22.0", + "check-es-compat": "^1.1.1", + "cordova-plugin-androidx-adapter": "^1.1.3", "cordova-plugin-screen-orientation": "^3.0.2", - "cross-env": "7.0.3", - "eslint": "7.25.0", - "eslint-config-prettier": "8.3.0", - "eslint-plugin-header": "3.1.1", - "eslint-plugin-import": "2.22.1", - "eslint-plugin-jest": "24.3.6", - "eslint-plugin-jsdoc": "32.3.3", - "eslint-plugin-prefer-arrow": "1.2.3", - "eslint-plugin-promise": "5.1.0", - "faker": "5.1.0", - "fs-extra": "9.1.0", + "cross-env": "^7.0.3", + "eslint": "^7.25.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jest": "^24.3.6", + "eslint-plugin-jsdoc": "^32.3.3", + "eslint-plugin-prefer-arrow": "^1.2.3", + "eslint-plugin-promise": "^5.1.0", + "faker": "^5.1.0", + "fs-extra": "^9.1.0", "gulp": "4.0.2", - "gulp-clip-empty-files": "0.1.2", - "gulp-concat": "2.6.1", - "gulp-flatten": "0.4.0", - "gulp-htmlmin": "5.0.1", - "gulp-rename": "2.0.0", - "gulp-slash": "1.1.3", - "jest": "26.5.2", - "jest-preset-angular": "8.3.1", - "jsonc-parser": "2.3.1", - "native-run": "1.4.0", - "terser-webpack-plugin": "4.2.3", - "ts-jest": "26.4.1", - "ts-node": "8.3.0", - "typescript": "3.9.9" + "gulp-clip-empty-files": "^0.1.2", + "gulp-concat": "^2.6.1", + "gulp-flatten": "^0.4.0", + "gulp-htmlmin": "^5.0.1", + "gulp-rename": "^2.0.0", + "gulp-slash": "^1.1.3", + "jest": "^26.5.2", + "jest-preset-angular": "^8.3.1", + "jsonc-parser": "^2.3.1", + "native-run": "^1.4.0", + "terser-webpack-plugin": "^4.2.3", + "ts-jest": "^26.4.1", + "ts-node": "~8.3.0", + "typescript": "^3.9.9" }, "engines": { "node": ">=14.15.0 <15" @@ -243,6 +243,6 @@ } }, "optionalDependencies": { - "keytar": "7.2.0" + "keytar": "^7.2.0" } } diff --git a/scripts/build-behat-plugin.js b/scripts/build-behat-plugin.js index e5d62c6bc..1d0570d7b 100755 --- a/scripts/build-behat-plugin.js +++ b/scripts/build-behat-plugin.js @@ -14,37 +14,82 @@ // See the License for the specific language governing permissions and // limitations under the License. -const { existsSync, readFileSync, writeFileSync } = require('fs'); +const { existsSync, readFileSync, writeFileSync, statSync, renameSync, rmSync } = require('fs'); const { readdir } = require('fs').promises; const { mkdirSync, copySync } = require('fs-extra'); -const { resolve } = require('path'); +const { resolve, extname, dirname, basename, relative } = require('path'); async function main() { const pluginPath = process.argv[2] || guessPluginPath() || fail('Folder argument missing!'); if (!existsSync(pluginPath)) { mkdirSync(pluginPath); + } else { + // Empty directory, except the excluding list. + const excludeFromErase = [ + '.git', + '.gitignore', + 'README.md', + ]; + + const files = await readdir(pluginPath, { withFileTypes: true }); + for (const file of files) { + if (excludeFromErase.indexOf(file.name) >= 0) { + continue; + } + + const path = resolve(pluginPath, file.name); + rmSync(`${path}`, {recursive: true}); + } } + // Copy plugin template. 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(), }; + writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); - copySync(templatePath, pluginPath); + // Copy feature files. + const behatTempFeaturesPath = `${pluginPath}/behat-tmp`; + copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory }); - for await (const templateFilePath of getDirectoryFiles(templatePath)) { - const pluginFilePath = pluginPath + templateFilePath.substr(templatePath.length); - const fileContents = readFileSync(pluginFilePath).toString(); - - writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); + const behatFeaturesPath = `${pluginPath}/tests/behat`; + if (!existsSync(behatFeaturesPath)) { + mkdirSync(behatFeaturesPath, {recursive: true}); } - // Copy features. - copySync(projectPath('tests/behat'), `${pluginPath}/tests/behat`); + for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) { + const featurePath = dirname(featureFile); + if (!featurePath.endsWith('/tests/behat')) { + continue; + } + + const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length)); + const searchRegExp = new RegExp('/', 'g'); + const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core'; + const featureFilename = prefix + '-' + basename(featureFile); + renameSync(featureFile, behatFeaturesPath + '/' + featureFilename); + } + + rmSync(behatTempFeaturesPath, {recursive: true}); +} + +function isFeatureFileOrDirectory(src) { + const stats = statSync(src); + + return stats.isDirectory() || extname(src) === '.feature'; } function fail(message) { 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/pages/general/general.ts b/src/core/features/settings/pages/general/general.ts index 9db912420..31ecf47e6 100644 --- a/src/core/features/settings/pages/general/general.ts +++ b/src/core/features/settings/pages/general/general.ts @@ -41,7 +41,7 @@ export class CoreSettingsGeneralPage { languages: { code: string; name: string }[] = []; selectedLanguage = ''; zoomLevels: { value: CoreZoomLevel; style: number; selected: boolean }[] = []; - selectedZoomLevel = CoreZoomLevel.NORMAL; + selectedZoomLevel = CoreZoomLevel.NONE; richTextEditor = true; debugDisplay = false; analyticsSupported = false; diff --git a/src/core/features/settings/services/settings-helper.ts b/src/core/features/settings/services/settings-helper.ts index 9f8a1d46a..dacb346ed 100644 --- a/src/core/features/settings/services/settings-helper.ts +++ b/src/core/features/settings/services/settings-helper.ts @@ -50,8 +50,8 @@ export const enum CoreColorScheme { * Constants to define zoom levels. */ export const enum CoreZoomLevel { - NORMAL = 'normal', - LOW = 'low', + NONE = 'none', + MEDIUM = 'medium', HIGH = 'high', } @@ -303,13 +303,13 @@ export class CoreSettingsHelperProvider { } // Reset the value to solve edge cases. - CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NORMAL); + CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NONE); if (fontSize < 100) { if (fontSize > 90) { CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.HIGH); } else if (fontSize > 70) { - CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.LOW); + CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.MEDIUM); } } @@ -326,7 +326,7 @@ export class CoreSettingsHelperProvider { * @return The saved zoom Level option. */ async getZoomLevel(): Promise { - return CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NORMAL); + return CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreConstants.CONFIG.defaultZoomLevel); } /** 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/update-manager.ts b/src/core/services/update-manager.ts index e73e50fd6..3d1280923 100644 --- a/src/core/services/update-manager.ts +++ b/src/core/services/update-manager.ts @@ -23,6 +23,7 @@ import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreSites } from './sites'; import { CoreUtils, PromiseDefer } from './utils/utils'; import { CoreApp } from './app'; +import { CoreZoomLevel } from '@features/settings/services/settings-helper'; const VERSION_APPLIED = 'version_applied'; @@ -71,6 +72,10 @@ export class CoreUpdateManagerProvider { promises.push(CoreH5P.h5pPlayer.deleteAllContentIndexes()); } + if (versionCode >= 41000 && versionApplied < 41000 && versionApplied > 0) { + promises.push(this.upgradeFontSizeNames()); + } + try { await Promise.all(promises); @@ -121,6 +126,19 @@ export class CoreUpdateManagerProvider { }); } + protected async upgradeFontSizeNames(): Promise { + const storedFontSizeName = await CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL); + switch (storedFontSizeName) { + case 'low': + await CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NONE); + break; + + case 'normal': + await CoreConfig.set(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.MEDIUM); + break; + } + } + } export const CoreUpdateManager = makeSingleton(CoreUpdateManagerProvider); diff --git a/src/core/services/urlschemes.ts b/src/core/services/urlschemes.ts index 131d2d41c..bbaf2f91c 100644 --- a/src/core/services/urlschemes.ts +++ b/src/core/services/urlschemes.ts @@ -108,7 +108,7 @@ export class CoreCustomURLSchemesProvider { // Some platforms like Windows add a slash at the end. Remove it. // Some sites add a # at the end of the URL. If it's there, remove it. - url = url.replace(/\/?#?\/?$/, ''); + url = url.replace(/\/?(#.*)?\/?$/, ''); const modal = await CoreDomUtils.showModalLoading(); let data: CoreCustomURLSchemesParams; diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index c6bbc23d4..e32d7d869 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -1792,12 +1792,12 @@ export class CoreDomUtilsProvider { const { waitForDismissCompleted, ...popoverOptions } = options; const popover = await PopoverController.create(popoverOptions); - const zoomLevel = await CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreZoomLevel.NORMAL); + const zoomLevel = await CoreConfig.get(CoreConstants.SETTINGS_ZOOM_LEVEL, CoreConstants.CONFIG.defaultZoomLevel); await popover.present(); // Fix popover position if zoom is applied. - if (zoomLevel !== CoreZoomLevel.NORMAL) { + if (zoomLevel !== CoreZoomLevel.NONE) { switch (getMode()) { case 'ios': fixIOSPopoverPosition(popover, options.event); 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/tests/behat/performance.feature b/src/tests/behat/performance.feature similarity index 93% rename from tests/behat/performance.feature rename to src/tests/behat/performance.feature index 39a41c5d1..950dfee4e 100644 --- a/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 diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 024c0dfb7..75c1e84f3 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -39,6 +39,7 @@ export interface EnvironmentConfig { wsservice: string; demo_sites: Record; zoomlevels: Record; + defaultZoomLevel?: CoreZoomLevel; // Set the default zoom level of the app. customurlscheme: string; siteurl: string; sitename: string; diff --git a/tests/behat/.gitkeep b/tests/behat/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/upgrade.txt b/upgrade.txt index b146617fb..ead37e419 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,6 +1,10 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 4.0.1 === + +- Zoom levels changed from "normal / low / high" to " none / medium / high". + === 4.0.0 === - The versioncode in moodle.config.json has changed from 4 digits to 5 digits to match the actual value for the stores: the 4.0.0 version's versioncode is now 40000 instead of 4000. If you maintain a Moodle plugin with mobile support and you use the versioncode that is sent in every request, you might need to check if this change will affect your code.