From 79bb346debe86737b7a9b05195d7a448d9325fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 12 May 2022 10:55:15 +0200 Subject: [PATCH] MOBILE-4061 behat: Split behat_app: steps and helper functions --- .../tests/behat/behat_app.php | 597 +---------------- .../tests/behat/behat_app_helper.php | 619 ++++++++++++++++++ 2 files changed, 621 insertions(+), 595 deletions(-) create mode 100644 local-moodleappbehat/tests/behat/behat_app_helper.php diff --git a/local-moodleappbehat/tests/behat/behat_app.php b/local-moodleappbehat/tests/behat/behat_app.php index 646f887d9..524ed7e3b 100644 --- a/local-moodleappbehat/tests/behat/behat_app.php +++ b/local-moodleappbehat/tests/behat/behat_app.php @@ -14,40 +14,15 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * 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 - */ - // 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; -/** - * 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; - -} - /** * Moodle App steps definitions. * @@ -56,62 +31,16 @@ interface behat_app_listener { * @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 array */ - protected static $listeners = []; +class behat_app extends behat_app_helper { /** @var string URL for running Ionic server */ protected $ionicurl = ''; - /** @var bool Whether the app is running or not */ - protected $apprunning = false; - /** @var array Config overrides */ protected $appconfig = ['disableUserTours' => true]; protected $windowsize = '360x720'; - /** - * Register listener. - * - * @param behat_app_listener $listener Listener. - * @return Closure Unregister function. - */ - public static function listen(behat_app_listener $listener): Closure { - self::$listeners[] = $listener; - - return function () use ($listener) { - $index = array_search($listener, self::$listeners); - - if ($index !== false) { - array_splice(self::$listeners, $index, 1); - } - }; - } - - /** - * Checks if the current OS is Windows, from the point of view of task-executing-and-killing. - * - * @return bool True if Windows - */ - protected static function is_windows() : bool { - return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; - } - - /** - * Called from behat_hooks when a new scenario starts, if it has the app tag. - * - * This updates Moodle configuration and starts Ionic running, if it isn't already. - */ - public function start_scenario() { - $this->check_behat_setup(); - $this->fix_moodle_setup(); - $this->ionicurl = $this->start_or_reuse_ionic(); - } - /** * Opens the Moodle App in the browser and optionally logs in. * @@ -320,255 +249,6 @@ class behat_app extends behat_base { $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 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(); - } - /** * Carries out the login steps for the app, assuming the user is on the app login page. Called * from behat_auth.php. @@ -1117,277 +797,4 @@ class behat_app extends behat_base { $this->execute_script("appProvider.setForceOffline($offline);"); } - /** - * 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. - */ - private function notify_load(): void { - foreach (self::$listeners as $listener) { - $listener->on_app_load(); - } - } - - /** - * Notify to listeners that the app is about to be unloaded. - */ - private function notify_unload(): void { - foreach (self::$listeners as $listener) { - $listener->on_app_unload(); - } - } - - /** - * Evaluate a script that returns a Promise. - * - * @param string $script - * @return mixed Resolved promise result. - */ - private 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; - } - - - /** - * Trigger Angular change detection. - */ - private function trigger_angular_change_detection() { - $this->getSession()->executeScript('ngZone.run(() => {});'); - } - - /** - * 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. - */ - private 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. - */ - private 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. - */ - private 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. - */ - private function get_mobile_url_scheme() { - $mobilesettings = get_config('tool_mobile'); - - return !empty($mobilesettings->forcedurlscheme) ? $mobilesettings->forcedurlscheme : 'moodlemobile'; - } - - /** - * Get a coursemodule from an activity name or idnumber with course. - * - * @param string $activity - * @param string $identifier - * @param string $coursename - * @return cm_info - */ - protected function get_cm_by_activity_name_and_course(string $activity, string $identifier, string $coursename): cm_info { - global $DB; - - $courseid = $this->get_course_id($coursename); - if (!$courseid) { - throw new DriverException("Course '$coursename' not found"); - } - - if ($activity === 'assignment') { - $activity = 'assign'; - } - - $cmtable = new \core\dml\table('course_modules', 'cm', 'cm'); - $cmfrom = $cmtable->get_from_sql(); - - $acttable = new \core\dml\table($activity, 'a', 'a'); - $actselect = $acttable->get_field_select(); - $actfrom = $acttable->get_from_sql(); - - $sql = <<get_record_sql($sql, [ - 'modname' => $activity, - 'idnumber' => $identifier, - 'name' => $identifier, - 'courseid' => $courseid, - ], MUST_EXIST); - - return get_fast_modinfo($courseid)->get_cm($result->cmid); - } } diff --git a/local-moodleappbehat/tests/behat/behat_app_helper.php b/local-moodleappbehat/tests/behat/behat_app_helper.php new file mode 100644 index 000000000..fc30cbed3 --- /dev/null +++ b/local-moodleappbehat/tests/behat/behat_app_helper.php @@ -0,0 +1,619 @@ +. + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. + +require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); + +use Behat\Mink\Exception\DriverException; + +/** + * Behat app listener. + */ +interface behat_app_listener { + + /** + * Called when the app is loaded. + */ + function on_app_load(): void; + + /** + * Called before the app is unloaded. + */ + function on_app_unload(): void; + +} + +/** + * A trait containing functionality used by the behat app context. + * + * @package core + * @category test + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_app_helper extends behat_base { + + /** @var stdClass Object with data about launched Ionic instance (if any) */ + protected static $ionicrunning = null; + + /** @var array */ + protected static $listeners = []; + + /** @var bool Whether the app is running or not */ + protected $apprunning = false; + + /** + * Register listener. + * + * @param behat_app_listener $listener Listener. + * @return Closure Unregister function. + */ + public static function listen(behat_app_listener $listener): Closure { + self::$listeners[] = $listener; + + return function () use ($listener) { + $index = array_search($listener, self::$listeners); + + if ($index !== false) { + array_splice(self::$listeners, $index, 1); + } + }; + } + + /** + * Checks if the current OS is Windows, from the point of view of task-executing-and-killing. + * + * @return bool True if Windows + */ + protected static function is_windows() : bool { + return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; + } + + /** + * Called from behat_hooks when a new scenario starts, if it has the app tag. + * + * This updates Moodle configuration and starts Ionic running, if it isn't already. + */ + public function start_scenario() { + $this->check_behat_setup(); + $this->fix_moodle_setup(); + $this->ionicurl = $this->start_or_reuse_ionic(); + } + + /** + * Checks the Behat setup - tags and configuration. + * + * @throws DriverException + */ + protected function check_behat_setup() { + global $CFG; + + // Check JavaScript is enabled. + if (!$this->running_javascript()) { + throw new DriverException('The app requires JavaScript.'); + } + + // Check the config settings are defined. + if (empty($CFG->behat_ionic_wwwroot) && empty($CFG->behat_ionic_dirroot)) { + throw new DriverException('$CFG->behat_ionic_wwwroot or $CFG->behat_ionic_dirroot must be defined.'); + } + } + + /** + * Fixes the Moodle admin settings to allow Moodle App use (if not already correct). + * + * @throws dml_exception If there is any problem changing Moodle settings + */ + protected function fix_moodle_setup() { + global $CFG, $DB; + + // Configure Moodle settings to enable app web services. + if (!$CFG->enablewebservices) { + set_config('enablewebservices', 1); + } + if (!$CFG->enablemobilewebservice) { + set_config('enablemobilewebservice', 1); + } + + // Add 'Create token' and 'Use REST webservice' permissions to authenticated user role. + $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']); + $systemcontext = \context_system::instance(); + role_change_permission($userroleid, $systemcontext, 'moodle/webservice:createtoken', CAP_ALLOW); + role_change_permission($userroleid, $systemcontext, 'webservice/rest:use', CAP_ALLOW); + + // Check the value of the 'webserviceprotocols' config option. Due to weird behaviour + // in Behat with regard to config variables that aren't defined in a settings.php, the + // value in $CFG here may reflect a previous run, so get it direct from the database + // instead. + $field = $DB->get_field('config', 'value', ['name' => 'webserviceprotocols'], IGNORE_MISSING); + if (empty($field)) { + $protocols = []; + } else { + $protocols = explode(',', $field); + } + if (!in_array('rest', $protocols)) { + $protocols[] = 'rest'; + set_config('webserviceprotocols', implode(',', $protocols)); + } + + // Enable mobile service. + require_once($CFG->dirroot . '/webservice/lib.php'); + $webservicemanager = new webservice(); + $service = $webservicemanager->get_external_service_by_shortname(MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST); + + if (!$service->enabled) { + $service->enabled = 1; + $webservicemanager->update_external_service($service); + } + } + + /** + * Starts an Ionic server if necessary, or uses an existing one. + * + * @return string URL to Ionic server + * @throws DriverException If there's a system error starting Ionic + */ + protected function start_or_reuse_ionic() { + global $CFG; + + if (empty($CFG->behat_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) { + // Use supplied Ionic server which should already be running. + $url = $CFG->behat_ionic_wwwroot; + } else if (self::$ionicrunning) { + // Use existing Ionic instance launched previously. + $url = self::$ionicrunning->url; + } else { + // Open Ionic process in relevant path. + $path = realpath($CFG->behat_ionic_dirroot); + $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log'; + $prefix = ''; + // Except on Windows, use 'exec' so that we get the pid of the actual Node process + // and not the shell it uses to execute. You can't do exec on Windows; there is a + // bypass_shell option but it is not the same thing and isn't usable here. + if (!self::is_windows()) { + $prefix = 'exec '; + } + $process = proc_open($prefix . 'ionic serve --no-interactive --no-open', + [['pipe', 'r'], ['pipe', 'w'], ['file', $stderrfile, 'w']], $pipes, $path); + if ($process === false) { + throw new DriverException('Error starting Ionic process'); + } + fclose($pipes[0]); + + // Get pid - we will need this to kill the process. + $status = proc_get_status($process); + $pid = $status['pid']; + + // Read data from stdout until the server comes online. + // Note: On Windows it is impossible to read simultaneously from stderr and stdout + // because stream_select and non-blocking I/O don't work on process pipes, so that is + // why stderr was redirected to a file instead. Also, this code is simpler. + $url = null; + $stdoutlog = ''; + while (true) { + $line = fgets($pipes[1], 4096); + if ($line === false) { + break; + } + + $stdoutlog .= $line; + + if (preg_match('~^\s*Local: (http\S*)~', $line, $matches)) { + $url = $matches[1]; + break; + } + } + + // If it failed, close the pipes and the process. + if (!$url) { + fclose($pipes[1]); + proc_close($process); + $logpath = $CFG->dataroot . '/behat/ionic-start.log'; + $stderrlog = file_get_contents($stderrfile); + @unlink($stderrfile); + file_put_contents($logpath, + "Ionic startup log from " . date('c') . + "\n\n----STDOUT----\n$stdoutlog\n\n----STDERR----\n$stderrlog"); + throw new DriverException('Unable to start Ionic. See ' . $logpath); + } + + // Remember the URL, so we can reuse it next time, and other details so we can kill + // the process. + self::$ionicrunning = (object)['url' => $url, 'process' => $process, 'pipes' => $pipes, + 'pid' => $pid]; + $url = self::$ionicrunning->url; + } + return $url; + } + + /** + * Closes Ionic (if it was started) at end of test suite. + * + * @AfterSuite + */ + public static function close_ionic() { + if (self::$ionicrunning) { + fclose(self::$ionicrunning->pipes[1]); + + if (self::is_windows()) { + // Using proc_terminate here does not work. It terminates the process but not any + // other processes it might have launched. Instead, we need to use an OS-specific + // mechanism to kill the process and children based on its pid. + exec('taskkill /F /T /PID ' . self::$ionicrunning->pid); + } else { + // On Unix this actually works, although only due to the 'exec' command inserted + // above. + proc_terminate(self::$ionicrunning->process); + } + self::$ionicrunning = null; + } + } + + /** + * Goes to the app page and then sets up some initial JavaScript so we can use it. + * + * @param string $url App URL + * @throws DriverException If the app fails to load properly + */ + protected function prepare_browser(array $options = []) { + $restart = $options['restart'] ?? true; + + if ($restart) { + if ($this->apprunning) { + $this->notify_unload(); + } + + // Restart the browser and set its size. + $this->getSession()->restart(); + $this->resize_window($this->windowsize, true); + + if (empty($this->ionicurl)) { + $this->ionicurl = $this->start_or_reuse_ionic(); + } + + // Visit the Ionic URL. + $this->getSession()->visit($this->ionicurl); + $this->notify_load(); + + $this->apprunning = true; + } + + // Wait the application to load. + $this->spin(function($context) { + $title = $context->getSession()->getPage()->find('xpath', '//title'); + + if ($title) { + $text = $title->getHtml(); + + if ($text === 'Moodle App') { + return true; + } + } + + throw new DriverException('Moodle App not found in browser'); + }, false, 60); + + try { + // Init Behat JavaScript runtime. + + $initOptions = new StdClass(); + $initOptions->skipOnBoarding = $options['skiponboarding'] ?? true; + $initOptions->configOverrides = $this->appconfig; + + $this->execute_script('window.behatInit(' . json_encode($initOptions) . ');'); + } catch (Exception $error) { + throw new DriverException('Moodle App not running or not running on Automated mode.'); + } + + if ($restart) { + // Assert initial page. + $this->spin(function($context) { + $page = $context->getSession()->getPage(); + $element = $page->find('xpath', '//page-core-login-site//input[@name="url"]'); + + if ($element) { + // Login screen found. + return true; + } + + if ($page->find('xpath', '//page-core-mainmenu')) { + // Main menu found. + return true; + } + + throw new DriverException('Moodle App not launched properly'); + }, false, 60); + } + + // Continue only after JS finishes. + $this->wait_for_pending_js(); + } + + /** + * Parse an element locator string. + * + * @param string $text Element locator string. + * @return JSON of the locator. + */ + public function parse_element_locator(string $text): string { + preg_match( + '/^"((?:[^"]|\\")*?)"(?: "([^"]*?)")?(?: (near|within) "((?:[^"]|\\")*?)"(?: "([^"]*?)")?)?$/', + $text, + $matches + ); + + $locator = [ + 'text' => str_replace('\\"', '"', $matches[1]), + 'selector' => $matches[2] ?? null, + ]; + + if (!empty($matches[3])) { + $locator[$matches[3]] = (object) [ + 'text' => str_replace('\\"', '"', $matches[4]), + 'selector' => $matches[5] ?? null, + ]; + } + + return json_encode((object) $locator); + } + + /** + * Replaces $WWWROOT for the url of the Moodle site. + * + * @Transform /^(.*\$WWWROOT.*)$/ + * @param string $text Text. + * @return string + */ + public function replace_wwwroot($text) { + global $CFG; + + return str_replace('$WWWROOT', $CFG->behat_wwwroot, $text); + } + + /** + * Replace arguments with the format "${activity:field}" from a string, where "activity" is + * the idnumber of an activity and "field" is the activity's field to get replacement from. + * + * At the moment, the only field supported is "cmid", the id of the course module for this activity. + * + * @param string $text Original text. + * @return string Text with arguments replaced. + */ + protected function replace_arguments(string $text): string { + global $DB; + + preg_match_all("/\\$\\{([^:}]+):([^}]+)\\}/", $text, $matches); + + foreach ($matches[0] as $index => $match) { + switch ($matches[2][$index]) { + case 'cmid': + $coursemodule = $DB->get_record('course_modules', ['idnumber' => $matches[1][$index]]); + $text = str_replace($match, $coursemodule->id, $text); + + break; + } + } + + return $text; + } + + /** + * Notify to listeners that the app was just loaded. + */ + protected function notify_load(): void { + foreach (self::$listeners as $listener) { + $listener->on_app_load(); + } + } + + /** + * Notify to listeners that the app is about to be unloaded. + */ + protected function notify_unload(): void { + foreach (self::$listeners as $listener) { + $listener->on_app_unload(); + } + } + + + /** + * Trigger Angular change detection. + */ + protected function trigger_angular_change_detection() { + $this->getSession()->executeScript('ngZone.run(() => {});'); + } + + /** + * Evaluate a script that returns a Promise. + * + * @param string $script + * @return mixed Resolved promise result. + */ + protected function evaluate_async_script(string $script) { + $script = preg_replace('/^return\s+/', '', $script); + $script = preg_replace('/;$/', '', $script); + $start = microtime(true); + $promisevariable = 'PROMISE_RESULT_' . time(); + $timeout = self::get_timeout(); + + $this->evaluate_script("Promise.resolve($script) + .then(result => window.$promisevariable = result) + .catch(error => window.$promisevariable = 'Async code rejected: ' + error?.message);"); + + do { + if (microtime(true) - $start > $timeout) { + throw new DriverException("Async script not resolved after $timeout seconds"); + } + + usleep(100000); + } while (!$this->evaluate_script("return '$promisevariable' in window;")); + + $result = $this->evaluate_script("return window.$promisevariable;"); + + $this->evaluate_script("delete window.$promisevariable;"); + + return $result; + } + + /** + * Opens a custom URL for automatic login and redirect from the Moodle App (and waits to finish.) + * + * @param string $username Of the user that needs to be logged in. + * @param string $path To redirect the user. + * @param string $successXPath If a path is declared, the XPath of the element to lookat after redirect. + */ + protected function open_moodleapp_custom_login_url($username, $path = '', string $successXPath = '') { + global $CFG, $DB; + + require_once($CFG->libdir.'/externallib.php'); + require_once($CFG->libdir.'/moodlelib.php'); + + // Ensure the user exists. + $userid = $DB->get_field('user', 'id', [ 'username' => $username ]); + if (!$userid) { + throw new DriverException("User '$username' not found"); + } + + // Get or create the user token. + $service = $DB->get_record('external_services', ['shortname' => 'moodle_mobile_app']); + + $token_params = [ + 'userid' => $userid, + 'externalserviceid' => $service->id, + ]; + $usertoken = $DB->get_record('external_tokens', $token_params); + if (!$usertoken) { + $context = context_system::instance(); + $token = external_generate_token(EXTERNAL_TOKEN_PERMANENT, $service, $userid, $context); + $token_params['token'] = $token; + $privatetoken = $DB->get_field('external_tokens', 'privatetoken', $token_params); + } else { + $token = $usertoken->token; + $privatetoken = $usertoken->privatetoken; + } + + // Generate custom URL. + $parsed_url = parse_url($CFG->behat_wwwroot); + $domain = $parsed_url['host']; + $url = $this->get_mobile_url_scheme() . "://$username@$domain?token=$token&privatetoken=$privatetoken"; + + if (!empty($path)) { + $url .= '&redirect='.urlencode($CFG->behat_wwwroot.$path); + } else { + $successXPath = '//page-core-mainmenu'; + } + + $this->handle_url_and_wait_page_to_load($url, $successXPath); + } + + /** + * Opens a custom URL on the Moodle App (and waits to finish.) + * + * @param string $path To navigate. + * @param string $successXPath The XPath of the element to lookat after navigation. + */ + protected function open_moodleapp_custom_url(string $path, string $successXPath = '') { + global $CFG; + + $urlscheme = $this->get_mobile_url_scheme(); + $url = "$urlscheme://link=" . urlencode($CFG->behat_wwwroot.$path); + + $this->handle_url_and_wait_page_to_load($url); + } + + /** + * Handles the custom URL on the Moodle App (and waits to finish.) + * + * @param string $customurl To navigate. + * @param string $successXPath The XPath of the element to lookat after navigation. + */ + protected function handle_url_and_wait_page_to_load(string $customurl, string $successXPath = '') { + // Instead of using evaluate_async_script, we wait for the path to load. + $this->evaluate_script("return window.behat.handleCustomURL('$customurl')"); + + $this->wait_for_pending_js(); + + if (!empty($successXPath)) { + // Wait until the page appears. + $this->spin( + function($context, $args) use ($successXPath) { + $found = $context->getSession()->getPage()->find('xpath', $successXPath); + if ($found) { + return true; + } + throw new DriverException('Moodle App custom URL page not loaded'); + }, false, 30); + + // Wait for JS to finish as well. + $this->wait_for_pending_js(); + } + } + + /** + * Returns the current mobile url scheme of the site. + */ + protected function get_mobile_url_scheme() { + $mobilesettings = get_config('tool_mobile'); + + return !empty($mobilesettings->forcedurlscheme) ? $mobilesettings->forcedurlscheme : 'moodlemobile'; + } + + /** + * Get a coursemodule from an activity name or idnumber with course. + * + * @param string $activity + * @param string $identifier + * @param string $coursename + * @return cm_info + */ + protected function get_cm_by_activity_name_and_course(string $activity, string $identifier, string $coursename): cm_info { + global $DB; + + $courseid = $this->get_course_id($coursename); + if (!$courseid) { + throw new DriverException("Course '$coursename' not found"); + } + + if ($activity === 'assignment') { + $activity = 'assign'; + } + + $cmtable = new \core\dml\table('course_modules', 'cm', 'cm'); + $cmfrom = $cmtable->get_from_sql(); + + $acttable = new \core\dml\table($activity, 'a', 'a'); + $actselect = $acttable->get_field_select(); + $actfrom = $acttable->get_from_sql(); + + $sql = <<get_record_sql($sql, [ + 'modname' => $activity, + 'idnumber' => $identifier, + 'name' => $identifier, + 'courseid' => $courseid, + ], MUST_EXIST); + + return get_fast_modinfo($courseid)->get_cm($result->cmid); + } +}