forked from EVOgeek/Vmeda.Online
		
	MOBILE-4061 behat: Split behat_app: steps and helper functions
This commit is contained in:
		
							parent
							
								
									2044576281
								
							
						
					
					
						commit
						79bb346deb
					
				| @ -14,40 +14,15 @@ | |||||||
| // You should have received a copy of the GNU General Public License
 | // You should have received a copy of the GNU General Public License
 | ||||||
| // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | ||||||
| 
 | 
 | ||||||
| /** |  | ||||||
|  * 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.
 | // 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__ . '/../../../../lib/behat/behat_base.php'); | ||||||
|  | require_once(__DIR__ . '/behat_app_helper.php'); | ||||||
| 
 | 
 | ||||||
| use Behat\Gherkin\Node\TableNode; | use Behat\Gherkin\Node\TableNode; | ||||||
| use Behat\Mink\Exception\DriverException; | use Behat\Mink\Exception\DriverException; | ||||||
| use Behat\Mink\Exception\ExpectationException; | 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. |  * Moodle App steps definitions. | ||||||
|  * |  * | ||||||
| @ -56,62 +31,16 @@ interface behat_app_listener { | |||||||
|  * @copyright 2018 The Open University |  * @copyright 2018 The Open University | ||||||
|  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later |  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | ||||||
|  */ |  */ | ||||||
| class behat_app extends behat_base { | class behat_app extends behat_app_helper { | ||||||
|     /** @var stdClass Object with data about launched Ionic instance (if any) */ |  | ||||||
|     protected static $ionicrunning = null; |  | ||||||
| 
 |  | ||||||
|     /** @var array */ |  | ||||||
|     protected static $listeners = []; |  | ||||||
| 
 | 
 | ||||||
|     /** @var string URL for running Ionic server */ |     /** @var string URL for running Ionic server */ | ||||||
|     protected $ionicurl = ''; |     protected $ionicurl = ''; | ||||||
| 
 | 
 | ||||||
|     /** @var bool Whether the app is running or not */ |  | ||||||
|     protected $apprunning = false; |  | ||||||
| 
 |  | ||||||
|     /** @var array Config overrides */ |     /** @var array Config overrides */ | ||||||
|     protected $appconfig = ['disableUserTours' => true]; |     protected $appconfig = ['disableUserTours' => true]; | ||||||
| 
 | 
 | ||||||
|     protected $windowsize = '360x720'; |     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. |      * 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(); |         $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 |      * Carries out the login steps for the app, assuming the user is on the app login page. Called | ||||||
|      * from behat_auth.php. |      * from behat_auth.php. | ||||||
| @ -1117,277 +797,4 @@ class behat_app extends behat_base { | |||||||
|         $this->execute_script("appProvider.setForceOffline($offline);"); |         $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 = <<<EOF |  | ||||||
|     SELECT cm.id as cmid |  | ||||||
|       FROM {$cmfrom} |  | ||||||
| INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname |  | ||||||
| INNER JOIN {$actfrom} ON cm.instance = a.id AND cm.course = :courseid |  | ||||||
|      WHERE cm.idnumber = :idnumber OR a.name = :name |  | ||||||
| EOF; |  | ||||||
| 
 |  | ||||||
|         $result = $DB->get_record_sql($sql, [ |  | ||||||
|             'modname' => $activity, |  | ||||||
|             'idnumber' => $identifier, |  | ||||||
|             'name' => $identifier, |  | ||||||
|             'courseid' => $courseid, |  | ||||||
|         ], MUST_EXIST); |  | ||||||
| 
 |  | ||||||
|         return get_fast_modinfo($courseid)->get_cm($result->cmid); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										619
									
								
								local-moodleappbehat/tests/behat/behat_app_helper.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										619
									
								
								local-moodleappbehat/tests/behat/behat_app_helper.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,619 @@ | |||||||
|  | <?php | ||||||
|  | // This file is part of Moodle - http://moodle.org/
 | ||||||
|  | //
 | ||||||
|  | // Moodle is free software: you can redistribute it and/or modify
 | ||||||
|  | // it under the terms of the GNU General Public License as published by
 | ||||||
|  | // the Free Software Foundation, either version 3 of the License, or
 | ||||||
|  | // (at your option) any later version.
 | ||||||
|  | //
 | ||||||
|  | // Moodle is distributed in the hope that it will be useful,
 | ||||||
|  | // but WITHOUT ANY WARRANTY; without even the implied warranty of
 | ||||||
|  | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | ||||||
|  | // GNU General Public License for more details.
 | ||||||
|  | //
 | ||||||
|  | // You should have received a copy of the GNU General Public License
 | ||||||
|  | // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | ||||||
|  | 
 | ||||||
|  | // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 | ||||||
|  | 
 | ||||||
|  | require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); | ||||||
|  | 
 | ||||||
|  | use Behat\Mink\Exception\DriverException; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Behat app listener. | ||||||
|  |  */ | ||||||
|  | interface behat_app_listener { | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Called when the app is loaded. | ||||||
|  |      */ | ||||||
|  |     function on_app_load(): void; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * Called before the app is unloaded. | ||||||
|  |      */ | ||||||
|  |     function on_app_unload(): void; | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * A trait containing functionality used by the behat app context. | ||||||
|  |  * | ||||||
|  |  * @package    core | ||||||
|  |  * @category   test | ||||||
|  |  * @copyright  2018 The Open University | ||||||
|  |  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | ||||||
|  |  */ | ||||||
|  | class behat_app_helper extends behat_base { | ||||||
|  | 
 | ||||||
|  |     /** @var stdClass Object with data about launched Ionic instance (if any) */ | ||||||
|  |     protected static $ionicrunning = null; | ||||||
|  | 
 | ||||||
|  |     /** @var array */ | ||||||
|  |     protected static $listeners = []; | ||||||
|  | 
 | ||||||
|  |     /** @var bool Whether the app is running or not */ | ||||||
|  |     protected $apprunning = false; | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * 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 = <<<EOF | ||||||
|  |     SELECT cm.id as cmid | ||||||
|  |       FROM {$cmfrom} | ||||||
|  | INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname | ||||||
|  | INNER JOIN {$actfrom} ON cm.instance = a.id AND cm.course = :courseid | ||||||
|  |      WHERE cm.idnumber = :idnumber OR a.name = :name | ||||||
|  | EOF; | ||||||
|  | 
 | ||||||
|  |         $result = $DB->get_record_sql($sql, [ | ||||||
|  |             'modname' => $activity, | ||||||
|  |             'idnumber' => $identifier, | ||||||
|  |             'name' => $identifier, | ||||||
|  |             'courseid' => $courseid, | ||||||
|  |         ], MUST_EXIST); | ||||||
|  | 
 | ||||||
|  |         return get_fast_modinfo($courseid)->get_cm($result->cmid); | ||||||
|  |     } | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user