. /** * Mobile/desktop app steps definitions. * * @package core * @category test * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); use Behat\Gherkin\Node\TableNode; use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\ExpectationException; /** * Mobile/desktop app steps definitions. * * @package core * @category test * @copyright 2018 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class behat_app extends behat_base { /** @var stdClass Object with data about launched Ionic instance (if any) */ protected static $ionicrunning = null; /** @var string URL for running Ionic server */ protected $ionicurl = ''; /** @var bool Checks whether the app is runing a legacy version (ionic 3) */ protected $islegacy; /** * Checks if the current OS is Windows, from the point of view of task-executing-and-killing. * * @return bool True if Windows */ protected static function is_windows() : bool { return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; } /** * Called from behat_hooks when a new scenario starts, if it has the app tag. * * This updates Moodle configuration and starts Ionic running, if it isn't already. */ public function start_scenario() { $this->check_behat_setup(); $this->fix_moodle_setup(); $this->ionicurl = $this->start_or_reuse_ionic(); } /** * Opens the Moodle app in the browser and introduces the enters the site. * * @Given /^I enter the app$/ * @throws DriverException Issue with configuration or feature file * @throws dml_exception Problem with Moodle setup * @throws ExpectationException Problem with resizing window */ public function i_enter_the_app() { $this->i_launch_the_app(); $this->enter_site(); } /** * Opens the Moodle app in the browser. * * @Given /^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() { // 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(); } /** * @Then /^I wait the app to restart$/ */ public function i_wait_the_app_to_restart() { // Wait window to reload. $this->spin(function() { return $this->evaluate_script("return !window.behat;"); }); // Prepare testing runtime again. $this->prepare_browser(false); } /** * Finds elements in the app. * * @Then /^I should( not)? find (".+") in the app$/ * @param bool $not * @param object $locator */ public function i_find_in_the_app(bool $not, object $locator) { $locatorjson = json_encode($locator); $this->spin(function() use ($not, $locatorjson) { $result = $this->evaluate_script("return window.behat.find($locatorjson);"); if ($not && $result === 'OK') { throw new DriverException('Error, found an item that should not be found'); } if (!$not && $result !== 'OK') { throw new DriverException('Error finding item - ' . $result); } return true; }); $this->wait_for_pending_js(); } /** * Check if elements are selected in the app. * * @Then /^(".+") should( not)? be selected in the app$/ * @param object $locator * @param bool $not */ public function be_selected_in_the_app(object $locator, bool $not = false) { $locatorjson = json_encode($locator); $this->spin(function() use ($locatorjson, $not) { $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);"); switch ($result) { case 'YES': if ($not) { throw new ExpectationException("Item was selected and shouldn't have", $this->getSession()->getDriver()); } break; case 'NO': if (!$not) { throw new ExpectationException("Item wasn't selected and should have", $this->getSession()->getDriver()); } break; default: throw new DriverException('Error finding item - ' . $result); } return true; }); $this->wait_for_pending_js(); } /** * Checks the Behat setup - tags and configuration. * * @throws DriverException */ protected function check_behat_setup() { global $CFG; // Check JavaScript is enabled. if (!$this->running_javascript()) { throw new DriverException('The app requires JavaScript.'); } // Check the config settings are defined. if (empty($CFG->behat_ionic_wwwroot) && empty($CFG->behat_ionic_dirroot)) { throw new DriverException('$CFG->behat_ionic_wwwroot or $CFG->behat_ionic_dirroot must be defined.'); } } /** * Fixes the Moodle admin settings to allow mobile app use (if not already correct). * * @throws dml_exception If there is any problem changing Moodle settings */ protected function fix_moodle_setup() { global $CFG, $DB; // Configure Moodle settings to enable app web services. if (!$CFG->enablewebservices) { set_config('enablewebservices', 1); } if (!$CFG->enablemobilewebservice) { set_config('enablemobilewebservice', 1); } // Add 'Create token' and 'Use REST webservice' permissions to authenticated user role. $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']); $systemcontext = \context_system::instance(); role_change_permission($userroleid, $systemcontext, 'moodle/webservice:createtoken', CAP_ALLOW); role_change_permission($userroleid, $systemcontext, 'webservice/rest:use', CAP_ALLOW); // Check the value of the 'webserviceprotocols' config option. Due to weird behaviour // in Behat with regard to config variables that aren't defined in a settings.php, the // value in $CFG here may reflect a previous run, so get it direct from the database // instead. $field = $DB->get_field('config', 'value', ['name' => 'webserviceprotocols'], IGNORE_MISSING); if (empty($field)) { $protocols = []; } else { $protocols = explode(',', $field); } if (!in_array('rest', $protocols)) { $protocols[] = 'rest'; set_config('webserviceprotocols', implode(',', $protocols)); } // Enable mobile service. require_once($CFG->dirroot . '/webservice/lib.php'); $webservicemanager = new webservice(); $service = $webservicemanager->get_external_service_by_shortname( MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST); if (!$service->enabled) { $service->enabled = 1; $webservicemanager->update_external_service($service); } // If installed, also configure local_mobile plugin to enable additional features service. $localplugins = core_component::get_plugin_list('local'); if (array_key_exists('mobile', $localplugins)) { $service = $webservicemanager->get_external_service_by_shortname( 'local_mobile', MUST_EXIST); if (!$service->enabled) { $service->enabled = 1; $webservicemanager->update_external_service($service); } } } /** * Starts an Ionic server if necessary, or uses an existing one. * * @return string URL to Ionic server * @throws DriverException If there's a system error starting Ionic */ protected function start_or_reuse_ionic() { global $CFG; if (empty($CFG->behat_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) { // Use supplied Ionic server which should already be running. $url = $CFG->behat_ionic_wwwroot; } else if (self::$ionicrunning) { // Use existing Ionic instance launched previously. $url = self::$ionicrunning->url; } else { // Open Ionic process in relevant path. $path = realpath($CFG->behat_ionic_dirroot); $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log'; $prefix = ''; // Except on Windows, use 'exec' so that we get the pid of the actual Node process // and not the shell it uses to execute. You can't do exec on Windows; there is a // bypass_shell option but it is not the same thing and isn't usable here. if (!self::is_windows()) { $prefix = 'exec '; } $process = proc_open($prefix . 'ionic serve --no-interactive --no-open', [['pipe', 'r'], ['pipe', 'w'], ['file', $stderrfile, 'w']], $pipes, $path); if ($process === false) { throw new DriverException('Error starting Ionic process'); } fclose($pipes[0]); // Get pid - we will need this to kill the process. $status = proc_get_status($process); $pid = $status['pid']; // Read data from stdout until the server comes online. // Note: On Windows it is impossible to read simultaneously from stderr and stdout // because stream_select and non-blocking I/O don't work on process pipes, so that is // why stderr was redirected to a file instead. Also, this code is simpler. $url = null; $stdoutlog = ''; while (true) { $line = fgets($pipes[1], 4096); if ($line === false) { break; } $stdoutlog .= $line; if (preg_match('~^\s*Local: (http\S*)~', $line, $matches)) { $url = $matches[1]; break; } } // If it failed, close the pipes and the process. if (!$url) { fclose($pipes[1]); proc_close($process); $logpath = $CFG->dataroot . '/behat/ionic-start.log'; $stderrlog = file_get_contents($stderrfile); @unlink($stderrfile); file_put_contents($logpath, "Ionic startup log from " . date('c') . "\n\n----STDOUT----\n$stdoutlog\n\n----STDERR----\n$stderrlog"); throw new DriverException('Unable to start Ionic. See ' . $logpath); } // Remember the URL, so we can reuse it next time, and other details so we can kill // the process. self::$ionicrunning = (object)['url' => $url, 'process' => $process, 'pipes' => $pipes, 'pid' => $pid]; $url = self::$ionicrunning->url; } return $url; } /** * Closes Ionic (if it was started) at end of test suite. * * @AfterSuite */ public static function close_ionic() { if (self::$ionicrunning) { fclose(self::$ionicrunning->pipes[1]); if (self::is_windows()) { // Using proc_terminate here does not work. It terminates the process but not any // other processes it might have launched. Instead, we need to use an OS-specific // mechanism to kill the process and children based on its pid. exec('taskkill /F /T /PID ' . self::$ionicrunning->pid); } else { // On Unix this actually works, although only due to the 'exec' command inserted // above. proc_terminate(self::$ionicrunning->process); } self::$ionicrunning = null; } } /** * Goes to the app page and then sets up some initial JavaScript so we can use it. * * @param string $url App URL * @throws DriverException If the app fails to load properly */ protected function prepare_browser(bool $restart = true) { if ($restart) { // Restart the browser and set its size. $this->getSession()->restart(); $this->resize_window('360x720', true); if (empty($this->ionicurl)) { $this->ionicurl = $this->start_or_reuse_ionic(); } // Check whether the app is running a legacy version. $json = @file_get_contents("{$this->ionicurl}/assets/env.json") ?: @file_get_contents("{$this->ionicurl}/config.json"); $data = json_decode($json); $appversion = $data->build->version ?? str_replace('-dev', '', $data->versionname); $this->islegacy = version_compare($appversion, '3.9.5', '<'); // Visit the Ionic URL. $this->getSession()->visit($this->ionicurl); } // Wait the application to load. $this->spin(function($context) { $title = $context->getSession()->getPage()->find('xpath', '//title'); if ($title) { $text = $title->getHtml(); if ( ($this->islegacy && $text === 'Moodle Desktop') || (!$this->islegacy && $text === 'Moodle App') ) { return true; } } throw new DriverException('Moodle app not found in browser'); }, false, 60); // Run the scripts to install Moodle 'pending' checks. $islegacyboolean = $this->islegacy ? 'true' : 'false'; $this->execute_script("window.BehatMoodleAppLegacy = $islegacyboolean;"); $this->execute_script(file_get_contents(__DIR__ . '/app_behat_runtime.js')); 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) { // Wait for the onboarding modal to open, if any. $this->wait_for_pending_js(); $element = $this->islegacy ? $page->find('xpath', '//page-core-login-site-onboarding') : $page->find('xpath', '//core-login-site-onboarding'); if ($element) { $this->i_press_in_the_app($this->parse_element_locator('"Skip"')); } // 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(); } 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($this->islegacy ? 'campus.example.edu' : 'Your site', $CFG->wwwroot); $this->i_press_in_the_app($this->parse_element_locator($this->islegacy ? '"Connect!"' : '"Connect to your site"')); $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($this->parse_element_locator('"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 'mainpage'; } throw new DriverException('Moodle app main page not loaded after login'); }, false, 30); // Wait for JS to finish as well. $this->wait_for_pending_js(); } /** * Presses standard buttons in the app. * * @Given /^I press the (back|main menu|page menu) button in the app$/ * @param string $button Button type * @throws DriverException If the button push doesn't work */ public function i_press_the_standard_button_in_the_app(string $button) { $this->spin(function() 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. * * @Given /^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->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:$/ */ public function i_open_a_custom_link(TableNode $data) { global $DB, $CFG; $data = (object) $data->getColumnsHash()[0]; $discussion = $DB->get_record('forum_discussions', ['name' => $data->discussion]); $pageurl = "{$CFG->behat_wwwroot}/mod/forum/discuss.php?d={$discussion->id}"; $url = "moodlemobile://link=" . urlencode($pageurl); $this->evaluate_script("return window.urlSchemes.handleCustomURL('$url')"); $this->wait_for_pending_js(); } /** * Closes a popup by clicking on the 'backdrop' behind it. * * @Given /^I close the popup in the app$/ * @throws DriverException If there isn't a popup to close */ public function i_close_the_popup_in_the_app() { $this->spin(function() { $result = $this->evaluate_script("return window.behat.closePopup();"); if ($result !== 'OK') { throw new DriverException('Error closing popup - ' . $result); } return true; }); $this->wait_for_pending_js(); } /** * Clicks on / touches something that is visible in the app. * * Note it is difficult to use the standard 'click on' or 'press' steps because those do not * distinguish visible items and the app always has many non-visible items in the DOM. * * @Then /^I press (".+") in the app$/ * @param object $locator Element locator * @throws DriverException If the press doesn't work */ public function i_press_in_the_app(object $locator) { $locatorjson = json_encode($locator); $this->spin(function() use ($locatorjson) { $result = $this->evaluate_script("return window.behat.press($locatorjson);"); 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 object $locator * @throws DriverException If the press doesn't work */ public function i_select_in_the_app(string $selectedtext, object $locator) { $selected = $selectedtext === 'select' ? 'YES' : 'NO'; $locatorjson = json_encode($locator); $this->spin(function() use ($selectedtext, $selected, $locatorjson) { // Don't do anything if the item is already in the expected state. $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);"); if ($result === $selected) { return true; } // Press item. $result = $this->evaluate_script("return window.behat.press($locatorjson);"); if ($result !== 'OK') { throw new DriverException('Error pressing item - ' . $result); } // Check that it worked as expected. $result = $this->evaluate_script("return window.behat.isSelected($locatorjson);"); 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(); } /** * 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); } /** * 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. * * @Given /^the app should( not)? have opened a browser tab$/ * @param bool $not */ public function the_app_should_have_opened_a_browser_tab(bool $not = false) { $this->spin(function() use ($not) { $openedbrowsertab = count($this->getSession()->getWindowNames()) === 2; if ($not === $openedbrowsertab) { 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() ); } return true; }); } /** * Switches to a newly-opened browser tab. * * This assumes the app opened a new tab. * * @Given /^I switch to the browser tab opened by the app$/ * @throws DriverException If there aren't exactly 2 tabs open */ public function i_switch_to_the_browser_tab_opened_by_the_app() { $names = $this->getSession()->getWindowNames(); if (count($names) !== 2) { throw new DriverException('Expected to see 2 tabs open, not ' . count($names)); } $this->getSession()->switchToWindow($names[1]); } /** * 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 multiple times in case some changes have // side-effects that result in further pending operations. for ($ticks = 5; $ticks > 0; $ticks--) { $session->executeScript($this->islegacy ? 'appRef.tick();' : 'changeDetector.detectChanges();'); } } /** * 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) { $session->executeScript($this->islegacy ? 'appRef.tick();' : 'changeDetector.detectChanges();'); $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)); } $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);"); } /** * Parse an element locator string. * * @Transform /^".+"$/ * @param string $text Element locator string. * @return object */ public function parse_element_locator($text): object { preg_match('/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: near "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/', $text, $matches); $locator = [ 'text' => str_replace('\\"', '"', $matches[1]), 'selector' => $matches[2] ?? null, ]; if (!empty($matches[3])) { $locator['near'] = (object) [ 'text' => str_replace('\\"', '"', $matches[3]), 'selector' => $matches[4] ?? null, ]; } return (object) $locator; } /** * Parse a negation string. * * @Transform /^not $/ * @param string $not Negation string. * @return bool */ public function parse_negation(string $not): bool { return !empty($not); } /** * 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; } }