diff --git a/htdocs/account/tests/behat/BehatAccount.php b/htdocs/account/tests/behat/BehatAccount.php new file mode 100644 index 0000000000000000000000000000000000000000..1d159755c3a83679d59d29bc9aa1ba0f600152ae --- /dev/null +++ b/htdocs/account/tests/behat/BehatAccount.php @@ -0,0 +1,32 @@ +shortoptions = array('e'); $options['adminemail']->description = get_string('cliadminemail', 'admin'); $options['adminemail']->required = true; +$options['sitename'] = new stdClass(); +$options['sitename']->examplevalue = 'Mahara site'; +$options['sitename']->shortoptions = array('n'); +$options['sitename']->description = get_string('clisitename', 'admin'); +$options['sitename']->required = false; + $settings = new stdClass(); $settings->options = $options; $settings->info = get_string('cliinstallerdescription', 'admin'); @@ -77,3 +83,10 @@ $userobj->commit(); // Password changes should be performed by the authfactory $authobj = AuthFactory::create($userobj->authinstance); $authobj->change_password($userobj, $adminpassword, true); + +// Set site name +if ($sitename = $cli->get_cli_param('sitename')) { + if (!set_config('sitename', $sitename)) { + cli::cli_exit(get_string('cliupdatesitenamefailed', 'admin'), true); + } +} diff --git a/htdocs/artefact/blog/tests/behat/AddBlogs.feature b/htdocs/artefact/blog/tests/behat/AddBlogs.feature new file mode 100644 index 0000000000000000000000000000000000000000..593aea963213e7ae8f0e44648e005979bd734f13 --- /dev/null +++ b/htdocs/artefact/blog/tests/behat/AddBlogs.feature @@ -0,0 +1,22 @@ +@javasript @plugin @artefact.blog +Feature: Mahara users can create their blogs + As a mahara user + I need to create blogs + + Scenario: create blogs + Given the following "users" exist: + | user | password | | institution | role | + | userA | Password1 | | mahara | member | + And the following account settings have set: + | Multiple journals | ON | + When I log in as "userA" with password "Password1" + And I follow "Content" + And I follow "Journal" + Then I should see "Journals" + When I press "Create journal" + And I fill in the following: + | title | My new journal | + | description |

This is my new journal

| + | tags | blog | + And I press "Create journal" + Then I should see "My new journal" \ No newline at end of file diff --git a/htdocs/composer.json b/htdocs/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..033411299c133f0c7d8f54c28684d4eecd416c22 --- /dev/null +++ b/htdocs/composer.json @@ -0,0 +1,16 @@ +{ + "require": { + "php": ">=5.3.2", + "behat/behat": "2.5.1", + "behat/mink": "1.5.0", + + "behat/mink-extension": "*", + + "behat/mink-goutte-driver": "*", + "behat/mink-selenium-driver": "*", + "behat/mink-selenium2-driver": "*" + }, + + "minimum-stability": "dev" +} + diff --git a/htdocs/init.php b/htdocs/init.php index 0ee179f04aa3cee52ad6c39b9f0beef7f4ab2590..01f9dffa7e0504624753970cd04ff2b6423c7824 100644 --- a/htdocs/init.php +++ b/htdocs/init.php @@ -16,7 +16,7 @@ if (defined('CLI') && php_sapi_name() != 'cli') { } $CFG = new StdClass; -$CFG->docroot = dirname(__FILE__) . '/'; +$CFG->docroot = dirname(__FILE__) . DIRECTORY_SEPARATOR; //array containing site options from database that are overrided by $CFG $OVERRIDDEN = array(); @@ -25,18 +25,10 @@ if (!empty($_SERVER['MAHARA_LIBDIR'])) { $CFG->libroot = $_SERVER['MAHARA_LIBDIR']; } else { - $CFG->libroot = dirname(__FILE__) . '/lib/'; + $CFG->libroot = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR; } set_include_path($CFG->libroot . PATH_SEPARATOR . $CFG->libroot . 'pear/' . PATH_SEPARATOR . get_include_path()); -// Ensure that, by default, the response is not cached -header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0'); -header('Expires: '. gmdate('D, d M Y H:i:s', 507686400) .' GMT'); -header('Pragma: no-cache'); - -// Prevent clickjacking through iframe tags -header('X-Frame-Options: SAMEORIGIN'); - // Set up error handling require('errors.php'); @@ -75,16 +67,6 @@ $CFG = (object)array_merge((array)$cfg, (array)$CFG); require_once('config-defaults.php'); $CFG = (object)array_merge((array)$cfg, (array)$CFG); -// Fix up paths in $CFG -foreach (array('docroot', 'dataroot') as $path) { - $CFG->{$path} = (substr($CFG->{$path}, -1) != '/') ? $CFG->{$path} . '/' : $CFG->{$path}; -} - -// Set default configs that are dependent on the docroot and dataroot -if (empty($CFG->sessionpath)) { - $CFG->sessionpath = $CFG->dataroot . 'sessions'; -} - // xmldb stuff $CFG->xmldbdisablenextprevchecking = true; $CFG->xmldbdisablecommentchecking = true; @@ -95,12 +77,82 @@ if (empty($CFG->directorypermissions)) { } $CFG->filepermissions = $CFG->directorypermissions & 0666; +if (defined('BEHAT_SITE_RUNNING')) { + // We already switched to behat test site previously. + +} +else if (!empty($CFG->behat_wwwroot) ||empty($CFG->behat_dataroot) || !empty($CFG->behat_dbprefix)) { + // The behat is configured on this server, we need to find out if this is the behat test + // site based on the URL used for access. + require_once($CFG->docroot . '/testing/frameworks/behat/lib.php'); + if (behat_is_test_site()) { + // Checking the integrity of the provided $CFG->behat_* vars and the + // selected wwwroot to prevent conflicts with production and phpunit environments. + behat_check_config_vars(); + + // Check that the directory does not contains other things. + if (!file_exists("$CFG->behat_dataroot/behattestdir.txt")) { + if ($dh = opendir($CFG->behat_dataroot)) { + while (($file = readdir($dh)) !== false) { + if ($file === 'behat' || $file === '.' || $file === '..' || $file === '.DS_Store') { + continue; + } + behat_error(BEHAT_EXITCODE_CONFIG, '$CFG->behat_dataroot directory is not empty, ensure this is the directory where you want to install behat test dataroot'); + } + closedir($dh); + unset($dh); + unset($file); + } + + if (defined('BEHAT_UTIL')) { + // Now we create dataroot directory structure for behat tests. + testing_initdataroot($CFG->behat_dataroot, 'behat'); + } else { + behat_error(BEHAT_EXITCODE_INSTALL); + } + } + + if (!defined('BEHAT_UTIL') && !defined('BEHAT_TEST')) { + // Somebody tries to access test site directly, tell them if not enabled. + if (!file_exists($CFG->behat_dataroot . '/behat/test_environment_enabled.txt')) { + behat_error(BEHAT_EXITCODE_CONFIG, 'Behat is configured but not enabled on this test site.'); + } + } + + // Constant used to inform that the behat test site is being used, + // this includes all the processes executed by the behat CLI command like + // the site reset, the steps executed by the browser drivers when simulating + // a user session and a real session when browsing manually to $CFG->behat_wwwroot + // like the browser driver does automatically. + // Different from BEHAT_TEST as only this last one can perform CLI + // actions like reset the site or use data generators. + define('BEHAT_SITE_RUNNING', true); + + // Clean extra config.php settings. + //behat_clean_init_config(); + + // Now we can begin switching $CFG->X for $CFG->behat_X. + $CFG->wwwroot = $CFG->behat_wwwroot; + $CFG->dbprefix = $CFG->behat_dbprefix; + $CFG->dataroot = $CFG->behat_dataroot; + } +} + +// Fix up paths in $CFG +foreach (array('docroot', 'dataroot') as $path) { + $CFG->{$path} = (substr($CFG->{$path}, -1) != DIRECTORY_SEPARATOR) ? $CFG->{$path} . DIRECTORY_SEPARATOR : $CFG->{$path}; +} + +// Set default configs that are dependent on the docroot and dataroot +if (empty($CFG->sessionpath)) { + $CFG->sessionpath = $CFG->dataroot . 'sessions'; +} + // Now that we've loaded the configs, we can override the default error settings // from errors.php $errorlevel = $CFG->error_reporting; error_reporting($errorlevel); set_error_handler('error', $errorlevel); - // core libraries require('mahara.php'); ensure_sanity(); @@ -321,7 +373,16 @@ if (!get_config('productionmode')) { $CFG->nocache = true; } -header('Content-type: text/html; charset=UTF-8'); +if (!defined('CLI')) { + header('Content-type: text/html; charset=UTF-8'); + // Ensure that, by default, the response is not cached + header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0'); + header('Expires: '. gmdate('D, d M Y H:i:s', 507686400) .' GMT'); + header('Pragma: no-cache'); + + // Prevent clickjacking through iframe tags + header('X-Frame-Options: SAMEORIGIN'); +} // Only do authentication once we know the page theme, so that the login form // can have the correct theming. @@ -374,7 +435,8 @@ if (!get_config('installed')) { ensure_install_sanity(); $scriptfilename = str_replace('\\', '/', $_SERVER['SCRIPT_FILENAME']); - if (false === strpos($scriptfilename, 'admin/index.php') + if (!defined('CLI') + && false === strpos($scriptfilename, 'admin/index.php') && false === strpos($scriptfilename, 'admin/upgrade.php') && false === strpos($scriptfilename, 'admin/upgrade.json.php') && false === strpos($scriptfilename, 'admin/cli/install.php') diff --git a/htdocs/js/mahara.js b/htdocs/js/mahara.js index 3a80c4c3007d3420c73aca0aa1645ca0a1ab914d..ad2f26876de69a137a363668e566abd6bdb7e892 100644 --- a/htdocs/js/mahara.js +++ b/htdocs/js/mahara.js @@ -863,3 +863,11 @@ jQuery(document).ready(function() { jQuery(document).ready(function() { jQuery('body').removeClass('no-js').addClass('js'); }); + +/** + * Check if the page is ready in javascript + */ +var is_page_ready = false; +jQuery(document).ready(function() { + is_page_ready = true; +}); diff --git a/htdocs/lang/en.utf8/admin.php b/htdocs/lang/en.utf8/admin.php index 0669b7f4bf89dddf1ff26110e61155b3fc326023..54b460bf7ca0b702f762d0c8a45ee408bd062e3a 100644 --- a/htdocs/lang/en.utf8/admin.php +++ b/htdocs/lang/en.utf8/admin.php @@ -55,6 +55,8 @@ $string['dbcollationmismatch'] = 'A column of your database is using a collation $string['maharainstalled'] = 'Mahara is already installed.'; $string['cliadminpassword'] = 'The password for the admin user'; $string['cliadminemail'] = 'The email address for the admin user'; +$string['clisitename'] = 'The site name'; +$string['cliupdatesitenamefailed'] = 'Updating site name failed.'; $string['cliinstallerdescription'] = 'Install Mahara and create required data directories'; $string['cliinstallingmahara'] = 'Installing Mahara'; $string['cliupgraderdescription'] = 'Upgrade the Mahara database and data to the version of Mahara installed'; diff --git a/htdocs/lang/en.utf8/behat.php b/htdocs/lang/en.utf8/behat.php new file mode 100644 index 0000000000000000000000000000000000000000..339397df24782b7e31d54dc1e7f0469f64ffa6db --- /dev/null +++ b/htdocs/lang/en.utf8/behat.php @@ -0,0 +1,19 @@ +behat_dataroot is not set or is invalid.'; +$string['errorsetconfig'] = '$CFG->behat_dataroot, $CFG->behat_dbprefix and $CFG->behat_wwwroot need to be set in config.php.'; +$string['erroruniqueconfig'] = '$CFG->behat_dataroot, $CFG->behat_dbprefix and $CFG->behat_wwwroot values need to be different than $CFG->dataroot, $CFG->dbprefix, $CFG->wwwroot, $CFG->phpunit_dataroot and $CFG->phpunit_prefix values.'; diff --git a/htdocs/lib/ddl.php b/htdocs/lib/ddl.php index cd5950469ad64da60b97c7a411aebfa5eeffda5d..469061d43a39af4d5a1d1140e2d0afac283a2a1e 100644 --- a/htdocs/lib/ddl.php +++ b/htdocs/lib/ddl.php @@ -1411,3 +1411,74 @@ function rename_index($table, $index, $newname, $continue=true, $feedback=true) return execute_sql_arr($sqlarr, $continue, $feedback); } + +/** + * Return all tables in current db + * + * @return array('tablename' => 'tablename', ...) + * Note all table names is in lower cases + */ +function get_tables() { + + global $CFG, $db; + + // Get all tables in current DB + $tables = $metatables = $db->MetaTables('TABLES'); + if (!empty($CFG->prefix)) { + $tables = array(); + foreach ($metatables as $mtable) { + if (strpos($mtable, $CFG->prefix) !== false) { + $tables[] = $mtable; + } + } + } + unset($metatables); + $tnames = array(); + foreach ($tables as $t) { + $t = strtolower($t); + $tnames[$t] = $t; + } + + return $tnames; +} + +/** + * Return all columns of a table in current db + * + * @param string $tablename should be a full name including the dbprefix + * @return array of ADOFieldObject + */ +function get_columns($tablename) { + + global $CFG, $db; + + $columns = $db->MetaColumns($tablename); + // Update the field Auto_increment if postgres + // Only apply for "id" field + if (is_postgres()) { + if (isset($columns['id'])) { + $idcolumn = $columns['id']; + if (isset($idcolumn->primary_key) && ($idcolumn->primary_key === 1) + && isset($idcolumn->default_value) + && strpos($idcolumn->default_value, 'nextval(') !== false ) { + $rec = get_record_sql('SELECT last_value FROM '. "{$tablename}" . '_id_seq'); + $idcolumn->Auto_increment = $rec->last_value + 1; + } + $columns['id'] = $idcolumn; + } + } + return $columns; +} + +/** + * Return the server info + * + * @return an array of containing two elements 'description' and 'version' + */ +function get_server_info() { + + global $CFG, $db; + + return $db->ServerInfo(); +} + diff --git a/htdocs/lib/file.php b/htdocs/lib/file.php index 176cfce4ef18041d8faf917cf0b568fea5b364cd..1d8183d43ef6b80ce39f80d878a636d4bdbe9ac7 100644 --- a/htdocs/lib/file.php +++ b/htdocs/lib/file.php @@ -876,3 +876,51 @@ function file_cleanup_old_cached_files() { } } } + +/** + * Create a directory and make sure it is writable. + * + * @private + * @param string $dir the full path of the directory to be created + * @param bool $exceptiononerror throw exception if error encountered + * @return string|false Returns full path to directory if successful, false if not; may throw exception + */ +function make_writable_directory($dir, $exceptiononerror = true) { + global $CFG; + + if (file_exists($dir) && !is_dir($dir)) { + if ($exceptiononerror) { + throw new SystemException($dir . ' directory can not be created, file with the same name already exists.'); + } + else { + return false; + } + } + + if (!file_exists($dir)) { + if (!mkdir($dir, $CFG->directorypermissions, true)) { + clearstatcache(); + // There might be a race condition when creating directory. + if (!is_dir($dir)) { + if ($exceptiononerror) { + throw new SystemException($dir . ' can not be created, check permissions.'); + } + else { + debugging('Can not create directory: ' . $dir, DEBUG_DEVELOPER); + return false; + } + } + } + } + + if (!is_writable($dir)) { + if ($exceptiononerror) { + throw new SystemException($dir . ' is not writable, check permissions.'); + } + else { + return false; + } + } + + return $dir; +} diff --git a/htdocs/lib/mahara.php b/htdocs/lib/mahara.php index af40570597cbab23b1cdfaee275201523e7de008..983b251c7e2043e492d94d0af88afd8b7f22d118 100644 --- a/htdocs/lib/mahara.php +++ b/htdocs/lib/mahara.php @@ -1594,6 +1594,11 @@ function generate_interaction_instance_class_name($type) { return 'Interaction' . ucfirst($type) . 'Instance'; } +function generate_generator_class_name() { + $args = func_get_args(); + return 'DataGenerator' . implode('', array_map('ucfirst', $args)); +} + function blocktype_namespaced_to_single($blocktype) { if (strpos($blocktype, '/') === false) { // system blocktype return $blocktype; @@ -4012,3 +4017,96 @@ function get_user_institution_comment_sort_order($userid = null) { } return $sortorder; } + +/** + * Returns all directories of installed plugins except for local + * from the current codebase. + * + * This is relatively slow and not fully cached, use with care! + * + * @return array ('plugintkey' => path, ...) + * For example, array ( + * 'artefact.blog' => $CFG->docroot . 'artefact/blog', + * 'blocktype.blog' => $CFG->docroot . 'artefact/blog/blocktype/blog', + * ... + * ) + */ +function get_installed_plugins_paths() { + $versions = array(); + // All installed plugins + $plugins = array(); + foreach (plugin_types_installed() as $plugin) { + $dirhandle = opendir(get_config('docroot') . $plugin); + while (false !== ($dir = readdir($dirhandle))) { + if (strpos($dir, '.') === 0 || 'CVS' == $dir) { + continue; + } + if (!is_dir(get_config('docroot') . $plugin . '/' . $dir)) { + continue; + } + try { + validate_plugin($plugin, $dir); + $plugins[] = array($plugin, $dir); + } + catch (InstallationException $_e) { + log_warn("Plugin $plugin $dir is not installable: " . $_e->GetMessage()); + } + + if ($plugin === 'artefact') { // go check it for blocks as well + $btlocation = get_config('docroot') . $plugin . '/' . $dir . '/blocktype'; + if (!is_dir($btlocation)) { + continue; + } + $btdirhandle = opendir($btlocation); + while (false !== ($btdir = readdir($btdirhandle))) { + if (strpos($btdir, '.') === 0 || 'CVS' == $btdir) { + continue; + } + if (!is_dir(get_config('docroot') . $plugin . '/' . $dir . '/blocktype/' . $btdir)) { + continue; + } + $plugins[] = array('blocktype', $dir . '/' . $btdir); + } + } + } + } + $pluginpaths = array(); + foreach ($plugins as $plugin) { + $plugintype = $plugin[0]; + $pluginname = $plugin[1]; + $pluginpath = "$plugin[0]/$plugin[1]"; + $pluginkey = "$plugin[0].$plugin[1]"; + + if ($plugintype == 'blocktype' && strpos($pluginname, '/') !== false) { + $bits = explode('/', $pluginname); + $pluginpath = 'artefact/' . $bits[0] . '/blocktype/' . $bits[1]; + } + + $pluginpaths[$pluginkey] = get_config('docroot') . $pluginpath; + } + + return $pluginpaths; +} + +/** + * Returns hash of all versions including core and all installed plugins except for local + * from the current codebase. + * + * This is relatively slow and not fully cached, use with care! + * + * @return string sha1 hash + */ +function get_all_versions_hash() { + $versions = array(); + // Get core version + require(get_config('libroot') . 'version.php'); + $versions['core'] = $config->version; + // All installed plugins + $pluginpaths = get_installed_plugins_paths(); + foreach ($pluginpaths as $pluginkey => $pluginpath) { + require($pluginpath . '/version.php'); + $versions[$pluginkey] = $config->version; + } + + return sha1(serialize($versions)); +} diff --git a/htdocs/testing/classes/NastyStrings.php b/htdocs/testing/classes/NastyStrings.php new file mode 100644 index 0000000000000000000000000000000000000000..166d8919265d1182bc85227bfbc31c1a6ddf4aa8 --- /dev/null +++ b/htdocs/testing/classes/NastyStrings.php @@ -0,0 +1,102 @@ + & < > & \' \\" \ \'$@NULL@$ @@TEST@@ \\\" \\ , ; : . 日本語­% %%', + '& \' \\" \ \'$@NULL@$ < > & < > @@TEST@@ \\\" \\ , ; : . 日本語­% %%', + '< > & < > & \' \\" \ \\\" \\ , ; : . \'$@NULL@$ @@TEST@@ 日本語­% %%', + '< > & < > & \' \\" \ \'$@NULL@$ 日本語­% %%@@TEST@@ \. \\" \\ , ; :', + '< > & < > \\\" \\ , ; : . 日本語& \' \\" \ \'$@NULL@$ @@TEST@@­% %%', + '\' \\" \ \'$@NULL@$ @@TEST@@ < > & < > & \\\" \\ , ; : . 日本語­% %%', + '\\\" \\ , ; : . 日本語­% < > & < > & \' \\" \ \'$@NULL@$ @@TEST@@ %%', + '< > & < > & \' \\" \ \'$@NULL@$ 日本語­% %% @@TEST@@ \\\" \\ . , ; :', + '. 日本語& \' \\" < > & < > \\ , ; : \ \'$@NULL@$ \\\" @@TEST@@­% %%', + '& \' \\" \ < > & < > \\\" \\ , ; : . 日本語\'$@NULL@$ @@TEST@@­% %%', + ); + + /** + * Already used nasty strings. + * + * This array will be cleaned before each scenario. + * + * @static + * @var array + */ + protected static $usedstrings = array(); + + /** + * Returns a nasty string and stores the key mapping. + * + * @static + * @param string $key The key + * @return string + */ + public static function get($key) { + + // If have been used during the this tests return it. + if (isset(self::$usedstrings[$key])) { + return self::$strings[self::$usedstrings[$key]]; + } + + // Getting non-used random string. + do { + $index = self::random_index(); + } while (in_array($index, self::$usedstrings)); + + // Mark the string as already used. + self::$usedstrings[$key] = $index; + + return self::$strings[$index]; + } + + /** + * Resets the used strings var. + * @static + * @return void + */ + public static function reset_used_strings() { + self::$usedstrings = array(); + } + + /** + * Returns a random index. + * @static + * @return int + */ + protected static function random_index() { + return mt_rand(0, count(self::$strings) - 1); + } + +} diff --git a/htdocs/testing/classes/TestLock.php b/htdocs/testing/classes/TestLock.php new file mode 100644 index 0000000000000000000000000000000000000000..aeee985506937242234ead3b19534e4c4eb7bae3 --- /dev/null +++ b/htdocs/testing/classes/TestLock.php @@ -0,0 +1,80 @@ +{$framework . '_dataroot'} . '/' . $framework; + $lockfile = $datarootpath . '/lock'; + if (!file_exists($datarootpath)) { + // Dataroot not initialised yet. + return; + } + if (!file_exists($lockfile)) { + file_put_contents($lockfile, 'This file prevents concurrent execution of Mahara ' . $framework . ' tests'); + testing_fix_file_permissions($lockfile); + } + if (self::$lockhandles[$framework] = fopen($lockfile, 'r')) { + $wouldblock = null; + $locked = flock(self::$lockhandles[$framework], (LOCK_EX | LOCK_NB), $wouldblock); + if (!$locked) { + if ($wouldblock) { + echo "Waiting for other test execution to complete...\n"; + } + $locked = flock(self::$lockhandles[$framework], LOCK_EX); + } + if (!$locked) { + fclose(self::$lockhandles[$framework]); + self::$lockhandles[$framework] = null; + } + } + register_shutdown_function(array('TestLock', 'release'), $framework); + } + + /** + * Note: do not call manually! + * @internal + * @static + * @param string $framework phpunit|behat + * @return void + */ + public static function release($framework) { + if (self::$lockhandles[$framework]) { + flock(self::$lockhandles[$framework], LOCK_UN); + fclose(self::$lockhandles[$framework]); + self::$lockhandles[$framework] = null; + } + } + +} diff --git a/htdocs/testing/classes/TestsFinder.php b/htdocs/testing/classes/TestsFinder.php new file mode 100644 index 0000000000000000000000000000000000000000..683c3d1fc250c92945f4229ad83761d7c0aee113 --- /dev/null +++ b/htdocs/testing/classes/TestsFinder.php @@ -0,0 +1,165 @@ + $pluginpath) { + // Look for tests recursively + if (self::directory_has_tests($pluginpath, $testtype)) { + $pluginswithtests[$pluginkey] = $pluginpath; + } + } + return $pluginswithtests; + } + + /** + * Returns all the subsystems having tests + * + * Note we are hacking here the list of subsystems + * to cover some well-known subsystems. + * + * @param string $testtype The kind of test we are looking for + * @return array all the subsystems having tests + */ + private static function get_all_subsystems_with_tests($testtype) { + global $CFG; + + $subsystemspaths = array( + 'account' => $CFG->docroot . 'account', + 'collection' => $CFG->docroot . 'collection', + 'group' => $CFG->docroot . 'group', + 'skin' => $CFG->docroot . 'skin', + 'user' => $CFG->docroot . 'user', + 'view' => $CFG->docroot . 'view', + 'admin.users' => $CFG->docroot . 'admin/users', + 'admin.groups' => $CFG->docroot . 'admin/groups', + 'admin.site' => $CFG->docroot . 'admin/site', + ); + + $subsystemswithtests = array(); + foreach ($subsystemspaths as $subsystem => $subsystempath) { + // Look for tests recursively + if (self::directory_has_tests($subsystempath, $testtype)) { + $subsystemswithtests[$subsystem] = $subsystempath; + } + } + return $subsystemswithtests; + } + + /** + * Returns all the directories having tests + * + * @param string $testtype The kind of test we are looking for + * @return array all directories having tests + */ + private static function get_all_directories_with_tests($testtype) { + global $CFG; + + $dirs = array(); + $dirite = new RecursiveDirectoryIterator($CFG->docroot); + $iteite = new RecursiveIteratorIterator($dirite); + $regexp = self::get_regexp($testtype); + $regite = new RegexIterator($iteite, $regexp); + foreach ($regite as $path => $element) { + $key = dirname(dirname(dirname($path))); + $value = trim(str_replace('/', '.', str_replace($CFG->docroot, '', $key)), '.'); + $dirs[$key] = $value; + } + ksort($dirs); + return array_flip($dirs); + } + + /** + * Returns if a given directory has tests (recursively) + * + * @param string $dir full path to the directory to look for phpunit tests + * @param string $testtype phpunit|behat + * @return bool if a given directory has tests (true) or no (false) + */ + private static function directory_has_tests($dir, $testtype) { + if (!is_dir($dir)) { + return false; + } + + $dirite = new RecursiveDirectoryIterator($dir); + $iteite = new RecursiveIteratorIterator($dirite); + $regexp = self::get_regexp($testtype); + $regite = new RegexIterator($iteite, $regexp); + $regite->rewind(); + if ($regite->valid()) { + return true; + } + return false; + } + + + /** + * Returns the regular expression to match by the test files + * @param string $testtype + * @return string + */ + private static function get_regexp($testtype) { + + $sep = preg_quote(DIRECTORY_SEPARATOR, '|'); + + switch ($testtype) { + case 'phpunit': + $regexp = '|' . $sep . 'tests' . $sep . 'phpunit' . $sep . '.*\.php$|'; + break; + case 'features': + $regexp = '|' . $sep . 'tests' . $sep . 'behat' . $sep . '.*\.feature$|'; + break; + case 'stepsdefinitions': + $regexp = '|' . $sep . 'tests' . $sep . 'behat' . $sep . 'Behat.*\.php$|'; + break; + } + + return $regexp; + } +} diff --git a/htdocs/testing/classes/generator/data_generator_base.php b/htdocs/testing/classes/generator/data_generator_base.php new file mode 100644 index 0000000000000000000000000000000000000000..5f3893e748f689de778c1e785ad95e1e4fedee0a --- /dev/null +++ b/htdocs/testing/classes/generator/data_generator_base.php @@ -0,0 +1,55 @@ +datagenerator = $datagenerator; + } + + /** + * To be called from data reset code only, + * do not use in tests. + * @return void + */ + public function reset() { + $this->instancecount = 0; + } + + /** + * Create a test data record + * @param array|stdClass $record + * @param array $options + * @return stdClass the created record + */ + abstract public function create_instance($record = null, array $options = null); +} diff --git a/htdocs/testing/classes/generator/lib.php b/htdocs/testing/classes/generator/lib.php new file mode 100644 index 0000000000000000000000000000000000000000..1021e6ccc2a21bb1446fbcc0f8cabc3769f75058 --- /dev/null +++ b/htdocs/testing/classes/generator/lib.php @@ -0,0 +1,20 @@ +usercounter = 0; + $this->$groupcount = 0; + $this->$institutioncount = 0; + + foreach ($this->generators as $generator) { + $generator->reset(); + } + } + + /** + * Return generator for given plugin. + * @param string $plugintype the plugin type, e.g. 'artefact' or 'blocktype'. + * @param string $pluginname the plugin name, e.g. 'blog' or 'file'. + * @return an instance of a plugin generator extending from CoreGenerator. + */ + public function get_plugin_generator($plugintype, $pluginname) { + $pluginfullname = "{$plugintype}.{$pluginname}"; + if (isset($this->generators[$pluginfullname])) { + return $this->generators[$pluginfullname]; + } + safe_require($plugintype, $pluginname, 'tests/generator/lib.php'); + + $classname = generate_generator_class_name($plugintype, $pluginname); + + if (!class_exists($classname)) { + throw new SystemException("The plugin $pluginfullname does not support " . + "data generators yet. Class {$classname} not found."); + } + + $this->generators[$pluginfullname] = new $classname($this); + return $this->generators[$pluginfullname]; + + } + + /** + * Create a test user + * @param array|stdClass $record + * @param array $options + * @return stdClass user record + */ + public function create_user($record=null) { + } +} diff --git a/htdocs/testing/classes/util.php b/htdocs/testing/classes/util.php new file mode 100644 index 0000000000000000000000000000000000000000..101e6a39fce3fa8af37092ee7dee38160d19c8d8 --- /dev/null +++ b/htdocs/testing/classes/util.php @@ -0,0 +1,849 @@ +dataroot). + */ + private static $dataroot = null; + + /** + * @var testing_data_generator + */ + protected static $generator = null; + + /** + * @var string current version hash from php files + */ + protected static $versionhash = null; + + /** + * @var array original content of all database tables + */ + protected static $tabledata = null; + + /** + * @var array original structure of all database tables + */ + protected static $tablestructure = null; + + /** + * @var array original structure of all database tables + */ + protected static $sequencenames = null; + + /** + * @var string name of the json file where we store the list of dataroot files to not reset during reset_dataroot. + */ + private static $originaldatafilesjson = 'originaldatafiles.json'; + + /** + * @var boolean set to true once $originaldatafilesjson file is created. + */ + private static $originaldatafilesjsonadded = false; + + /** + * Return the name of the JSON file containing the init filenames. + * + * @static + * @return string + */ + public static function get_originaldatafilesjson() { + return self::$originaldatafilesjson; + } + + /** + * Return the dataroot. It's useful when mocking the dataroot when unit testing this class itself. + * + * @static + * @return string the dataroot. + */ + public static function get_dataroot() { + global $CFG; + + // By default it's the test framework dataroot. + if (empty(self::$dataroot)) { + self::$dataroot = $CFG->dataroot; + } + + return self::$dataroot; + } + + /** + * Set the dataroot. It's useful when mocking the dataroot when unit testing this class itself. + * + * @param string $dataroot the dataroot of the test framework. + * @static + */ + public static function set_dataroot($dataroot) { + self::$dataroot = $dataroot; + } + + /** + * Returns the testing framework name + * @static + * @return string + */ + protected static final function get_framework() { + $classname = get_called_class(); + return strtolower(substr($classname, 0, strpos($classname, 'TestingUtil'))); + } + + /** + * Get data generator + * @static + * @return testing_data_generator + */ + public static function get_data_generator() { + if (is_null(self::$generator)) { + require_once(dir(__FILE__) . '/generator/lib.php'); + self::$generator = new TestingDataGenerator(); + } + return self::$generator; + } + + /** + * Make sure the db and dataroot settings is for a test site + * + * @static + * @return bool + */ + public static function is_test_site() { + + $framework = self::get_framework(); + + if (!file_exists(self::get_dataroot() . '/' . $framework . 'testdir.txt')) { + // this is already tested in bootstrap script, + // but anyway presence of this file means the dataroot is for testing + return false; + } + + if (table_exists(new XMLDBTable('config'))) { + if (!get_config($framework . 'test')) { + return false; + } + } + + return true; + } + + /** + * Returns whether test database and dataroot were created using the current version codebase + * + * @return bool + */ + public static function is_test_data_updated() { + global $CFG; + + $framework = self::get_framework(); + + $datarootpath = self::get_dataroot() . '/' . $framework; + if (!file_exists($datarootpath . '/tabledata.ser') or !file_exists($datarootpath . '/tablestructure.ser')) { + return false; + } + + if (!file_exists($datarootpath . '/versionshash.txt')) { + return false; + } + + $hash = get_all_versions_hash(); + $oldhash = file_get_contents($datarootpath . '/versionshash.txt'); + + if ($hash !== $oldhash) { + return false; + } + + $dbhash = get_config($framework . 'test'); + if ($hash !== $dbhash) { + return false; + } + + return true; + } + + /** + * Stores the status of the database + * + * Serializes the contents and the structure and + * stores it in the test framework space in dataroot + */ + protected static function store_database_state() { + $framework = self::get_framework(); + + // store data for all tables + $data = array(); + $structure = array(); + $tables = get_tables(); + foreach ($tables as $table) { + $columns = get_columns($table); + $structure[$table] = $columns; + if (isset($columns['id']) && $columns['id']->Auto_increment) { + $data[$table] = get_records_sql_array('SELECT * FROM ' . db_quote_identifier($table) . ' ORDER BY id ASC', array()); + } + else { + // there should not be many of these + $data[$table] = get_records_sql_array('SELECT * FROM ' . db_quote_identifier($table), array()); + } + } + $data = serialize($data); + $datafile = self::get_dataroot() . '/' . $framework . '/tabledata.ser'; + file_put_contents($datafile, $data); + testing_fix_file_permissions($datafile); + + $structure = serialize($structure); + $structurefile = self::get_dataroot() . '/' . $framework . '/tablestructure.ser'; + file_put_contents($structurefile, $structure); + testing_fix_file_permissions($structurefile); + } + + /** + * Stores the version hash in both database and dataroot + */ + protected static function store_versions_hash() { + global $CFG; + + $framework = self::get_framework(); + $hash = get_all_versions_hash(); + + // add test db flag + set_config($framework . 'test', $hash); + + // hash all plugin versions - helps with very fast detection of db structure changes + $hashfile = self::get_dataroot() . '/' . $framework . '/versionshash.txt'; + file_put_contents($hashfile, $hash); + testing_fix_file_permissions($hashfile); + } + + /** + * Returns contents of all tables right after installation. + * @static + * @return array $table=>$records + */ + protected static function get_tabledata() { + $framework = self::get_framework(); + + $datafile = self::get_dataroot() . '/' . $framework . '/tabledata.ser'; + if (!file_exists($datafile)) { + // Not initialised yet. + return array(); + } + + if (!isset(self::$tabledata)) { + $data = file_get_contents($datafile); + self::$tabledata = unserialize($data); + } + + if (!is_array(self::$tabledata)) { + testing_error(1, 'Can not read dataroot/' . $framework . '/tabledata.ser or invalid format, reinitialize test database.'); + } + + return self::$tabledata; + } + + /** + * Returns structure of all tables right after installation. + * @static + * @return array $table=>$records + */ + public static function get_tablestructure() { + $framework = self::get_framework(); + + $structurefile = self::get_dataroot() . '/' . $framework . '/tablestructure.ser'; + if (!file_exists($structurefile)) { + // Not initialised yet. + return array(); + } + + if (!isset(self::$tablestructure)) { + $data = file_get_contents($structurefile); + self::$tablestructure = unserialize($data); + } + + if (!is_array(self::$tablestructure)) { + testing_error(1, 'Can not read dataroot/' . $framework . '/tablestructure.ser or invalid format, reinitialize test database.'); + } + + return self::$tablestructure; + } + + /** + * Returns the names of sequences for each autoincrementing id field in all standard tables. + * @static + * @return array $table=>$sequencename + */ + public static function get_sequencenames() { + if (isset(self::$sequencenames)) { + return self::$sequencenames; + } + + if (!$structure = self::get_tablestructure()) { + return array(); + } + + self::$sequencenames = array(); + foreach ($structure as $table => $ignored) { + $name = find_sequence_name(new XMLDBTable($table)); + if ($name !== false) { + self::$sequencenames[$table] = $name; + } + } + + return self::$sequencenames; + } + + /** + * Returns list of tables that are unmodified and empty. + * + * @static + * @return array of table names, empty if unknown + */ + protected static function guess_unmodified_empty_tables() { + $empties = array(); + if (is_mysql()) { + $prefix = get_config('dbprefix'); + $records = get_records_sql_array("SHOW TABLE STATUS LIKE ?", array($prefix . '%')); + foreach ($records as $info) { + $table = strtolower($info->Name); + if (strpos($table, $prefix) !== 0) { + // incorrect table match caused by _ + continue; + } + if (!empty($info->Auto_increment)) { + $table = preg_replace('/^' . preg_quote($prefix, '/') . '/', '', $table); + if ($info->Auto_increment === 1) { + $empties[$table] = $table; + } + } + } + unset($records); + } + else if (is_postgres()) { + $tables = get_tables(); + foreach ($tables as $table) { + $columns = get_columns($table); + if (isset($columns['id']) && isset($columns['id']->Auto_increment) && $columns['id']->Auto_increment === 1) { + $empties[$table] = $table; + } + } + } + return $empties; + } + + /** + * Reset all database sequences to initial values. + * + * @static + * @param array $empties tables that are known to be unmodified and empty + * @return void + */ + public static function reset_all_database_sequences(array $empties = null) { + if (!$data = self::get_tabledata()) { + // Not initialised yet. + return; + } + if (!$structure = self::get_tablestructure()) { + // Not initialised yet. + return; + } + + db_begin(); + $prefix = get_config('dbprefix'); + if (is_postgres()) { + $queries = array(); + foreach ($data as $table => $records) { + if (isset($structure[$table]['id']) + && !empty($structure[$table]['id']->Auto_increment) + ) { + if (empty($records)) { + $nextid = 1; + } else { + $lastrecord = end($records); + $nextid = $lastrecord->id + 1; + } + $queries[] = "ALTER SEQUENCE {$prefix}{$table}_id_seq RESTART WITH $nextid"; + } + } + if ($queries) { + execute_sql_arr(implode(';', $queries)); + } + + } + else if (is_mysql()) { + $sequences = array(); + $records = get_records_sql_array("SHOW TABLE STATUS LIKE ?", array($prefix . '%')); + foreach ($records as $info) { + $table = strtolower($info->Name); + if (strpos($table, $prefix) !== 0) { + // incorrect table match caused by _ + continue; + } + if (!empty($info->Auto_increment)) { + $table = preg_replace('/^' . preg_quote($prefix, '/') . '/', '', $table); + $sequences[$table] = $info->Auto_increment; + } + } + unset($records); + foreach ($data as $table => $records) { + if (isset($structure[$table]['id']) && $structure[$table]['id']->Auto_increment) { + if (isset($sequences[$table])) { + if (empty($records)) { + $nextid = 1; + } + else { + $lastrecord = end($records); + $nextid = $lastrecord->id + 1; + } + if ($sequences[$table] != $nextid) { + execute_sql("ALTER TABLE {$prefix}{$table} AUTO_INCREMENT = $nextid"); + } + + } + } + } + + } + db_commit(); + } + + /** + * Reset all database tables to default values. + * @static + * @return bool true if reset done, false if skipped + */ + public static function reset_database() { + $tables = get_tables(); + $prefix = get_config('dbprefix'); + + if (empty($tables) || !isset($tables["{$prefix}config"])) { + // not installed yet + return false; + } + + if (!$data = self::get_tabledata()) { + // not initialised yet + return false; + } + if (!$structure = self::get_tablestructure()) { + // not initialised yet + return false; + } + + $empties = self::guess_unmodified_empty_tables(); + + db_begin(); + $brokedmysql = false; + if (is_mysql()) { + $serverinfo = get_server_info(); + $version = $serverinfo['version']; + if (version_compare($version, '5.6.0') == 1 and version_compare($version, '5.6.16') == -1) { + // Everything that comes from Oracle is evil! + // + // See http://dev.mysql.com/doc/refman/5.6/en/alter-table.html + // You cannot reset the counter to a value less than or equal to to the value that is currently in use. + // + // From 5.6.16 release notes: + // InnoDB: The ALTER TABLE INPLACE algorithm would fail to decrease the auto-increment value. + // (Bug #17250787, Bug #69882) + $brokedmysql = true; + + } else if (version_compare($version, '10.0.0') == 1) { + // And MariaDB is no better! + // Let's hope they pick the patch sometime later... + $brokedmysql = true; + } + } + + if ($brokedmysql) { + $mysqlsequences = array(); + $records = get_records_sql_array("SHOW TABLE STATUS LIKE ?", array($prefix . '%')); + foreach ($records as $info) { + $table = strtolower($info->Name); + if (strpos($table, $prefix) !== 0) { + // incorrect table match caused by _ + continue; + } + if (!is_null($info->Auto_increment)) { + $table = preg_replace('/^' . preg_quote($prefix, '/') . '/', '', $table); + $mysqlsequences[$table] = $info->Auto_increment; + } + } + unset($records); + } + + // Temporary disable foreign key check + if (is_mysql()) { + execute_sql('SET foreign_key_checks = 0'); + } + foreach ($data as $table => $records) { + if (is_postgres()) { + execute_sql('ALTER TABLE ' . db_quote_identifier($table) . 'DISABLE TRIGGER ALL'); + } + if ($brokedmysql) { + if (empty($records) && isset($empties[$table])) { + continue; + } + + if (isset($structure[$table]['id']) && $structure[$table]['id']->Auto_increment) { + $current = get_records_sql_array('SELECT * FROM ' . db_quote_identifier($table) . ' ORDER BY id ASC', array()); + if ($current == $records) { + if (isset($mysqlsequences[$table]) && $mysqlsequences[$table] == $structure[$table]['id']->Auto_increment) { + continue; + } + } + } + + // Empty the table and reinsert everything. + execute_sql('DELETE FROM ' . db_quote_identifier($table)); + foreach ($records as $record) { + insert_record(substr($table, strlen($prefix)), $record); + } + continue; + } + + if (empty($records)) { + if (isset($empties[$table])) { + // table was not modified and is empty + } + else { + execute_sql('DELETE FROM ' . db_quote_identifier($table)); + } + continue; + } + + if (isset($structure[$table]['id']) and $structure[$table]['id']->Auto_increment) { + $currentrecords = get_records_sql_array('SELECT * FROM ' . db_quote_identifier($table) . ' ORDER BY id ASC', array()); + $changed = false; + foreach ($records as $id => $record) { + if (!isset($currentrecords[$id])) { + $changed = true; + break; + } + if ((array)$record != (array)$currentrecords[$id]) { + $changed = true; + break; + } + unset($currentrecords[$id]); + } + if (!$changed) { + if ($currentrecords) { + $lastrecord = end($records); + execute_sql('DELETE FROM ' . db_quote_identifier($table) . ' WHERE id > ?', array($lastrecord->id)); + } + continue; + } + } + + execute_sql('DELETE FROM ' . db_quote_identifier($table)); + foreach ($records as $record) { + insert_record(substr($table, strlen($prefix)), $record); + } + if (is_postgres()) { + execute_sql('ALTER TABLE ' . db_quote_identifier($table) . 'ENABLE TRIGGER ALL'); + } + } + // Enable foreign key check + if (is_mysql()) { + execute_sql('SET foreign_key_checks = 1'); + } + + db_commit(); + + // reset all next record ids - aka sequences + self::reset_all_database_sequences($empties); + + // remove extra tables + foreach ($tables as $table) { + if (!isset($data[$table])) { + drop_table(new XMLDBTable(substr($table, strlen($prefix)))); + } + } + + return true; + } + + /** + * Purge dataroot directory + * @static + * @return void + */ + public static function reset_dataroot() { + global $CFG; + + $childclassname = self::get_framework() . 'TestingUtil'; + + // Do not delete automatically installed files. + self::skip_original_data_files($childclassname); + + // Clean up the dataroot folder. + $handle = opendir(self::get_dataroot()); + while (false !== ($item = readdir($handle))) { + if (in_array($item, $childclassname::$datarootskiponreset)) { + continue; + } + rmdirr(self::get_dataroot() . "/$item"); + } + closedir($handle); + + // Clean up the dataroot/artefact folder. + if (file_exists(self::get_dataroot() . '/artefact')) { + $handle = opendir(self::get_dataroot() . '/artefact'); + while (false !== ($item = readdir($handle))) { + if (in_array('artefact/' . $item, $childclassname::$datarootskiponreset)) { + continue; + } + rmdirr(self::get_dataroot()."/artefact/$item"); + } + closedir($handle); + } + + } + + /** + * Gets a text-based site version description. + * + * @return string The site info + */ + public static function get_site_info() { + global $CFG; + + $output = ''; + + $release = null; + require("$CFG->docroot/lib/version.php"); + + $output .= "Mahara $release, $CFG->dbtype"; + if ($hash = self::get_git_hash()) { + $output .= ", $hash"; + } + $output .= "\n"; + + return $output; + } + + /** + * Try to get current git hash of the Mahara in $CFG->docroot. + * @return string null if unknown, sha1 hash if known + */ + public static function get_git_hash() { + global $CFG; + + // This is a bit naive, but it should mostly work for all platforms. + + if (!file_exists("$CFG->docroot/.git/HEAD")) { + return null; + } + + $headcontent = file_get_contents("$CFG->docroot/.git/HEAD"); + if ($headcontent === false) { + return null; + } + + $headcontent = trim($headcontent); + + // If it is pointing to a hash we return it directly. + if (strlen($headcontent) === 40) { + return $headcontent; + } + + if (strpos($headcontent, 'ref: ') !== 0) { + return null; + } + + $ref = substr($headcontent, 5); + + if (!file_exists("$CFG->docroot/.git/$ref")) { + return null; + } + + $hash = file_get_contents("$CFG->docroot/.git/$ref"); + + if ($hash === false) { + return null; + } + + $hash = trim($hash); + + if (strlen($hash) != 40) { + return null; + } + + return $hash; + } + + /** + * Drop the whole test database + * @static + * @param bool $displayprogress + */ + protected static function drop_database($displayprogress = false) { + + // Drop triggers + try { + db_drop_trigger('update_unread_insert', 'notification_internal_activity'); + db_drop_trigger('update_unread_update', 'notification_internal_activity'); + db_drop_trigger('update_unread_delete', 'notification_internal_activity'); + db_drop_trigger('unmark_quota_exeed_notified_on_update_setting', 'artefact_config'); + db_drop_trigger('unmark_quota_exeed_notified_on_update_usr_setting', 'usr'); + } + catch (Exception $e) { + exit(1); + } + + // Drop plugins' tables + log_info('Uninstalling plugins'); + $dotsonline = 0; + foreach (array_reverse(plugin_types_installed()) as $t) { + if ($installed = plugins_installed($t, true)) { + foreach ($installed as $p) { + $location = get_config('docroot') . $t . '/' . $p->name. '/db/'; + log_info('Uninstalling ' . $location); + if (is_readable($location . 'install.xml')) { + uninstall_from_xmldb_file($location . 'install.xml'); + } + if ($dotsonline == 60) { + if ($displayprogress) { + echo "\n"; + } + $dotsonline = 0; + } + if ($displayprogress) { + echo '.'; + } + $dotsonline += 1; + } + } + } + if ($displayprogress) { + echo "\n"; + } + // These constraints must be dropped manually as they cannot be + // created with xmldb due to ordering issues + try { + if (is_postgres()) { + execute_sql('ALTER TABLE {usr} DROP CONSTRAINT {usr_pro_fk}'); + execute_sql('ALTER TABLE {institution} DROP CONSTRAINT {inst_log_fk}'); + } + else { + execute_sql('ALTER TABLE {usr} DROP FOREIGN KEY {usr_pro_fk}'); + execute_sql('ALTER TABLE {institution} DROP FOREIGN KEY {inst_log_fk}'); + } + } + catch (Exception $e) { + exit(1); + } + + // now uninstall core + log_info('Uninstalling core'); + uninstall_from_xmldb_file(get_config('docroot') . 'lib/db/install.xml'); + + } + + /** + * Drops the test framework dataroot + * @static + */ + protected static function drop_dataroot() { + global $CFG; + + $framework = self::get_framework(); + $childclassname = $framework . 'TestingUtil'; + + $files = scandir(self::get_dataroot() . '/' . $framework); + foreach ($files as $file) { + if (in_array($file, $childclassname::$datarootskipondrop)) { + continue; + } + $path = self::get_dataroot() . '/' . $framework . '/' . $file; + rmdirr($path); + } + + $jsonfilepath = self::get_dataroot() . '/' . self::$originaldatafilesjson; + if (file_exists($jsonfilepath)) { + // Delete the json file. + unlink($jsonfilepath); + // Delete the dataroot artefact. + rmdirr(self::get_dataroot() . '/artefact'); + } + } + + /** + * Skip the original dataroot files to not been reset. + * + * @static + * @param string $utilclassname the util class name.. + */ + protected static function skip_original_data_files($utilclassname) { + $jsonfilepath = self::get_dataroot() . '/' . self::$originaldatafilesjson; + if (file_exists($jsonfilepath)) { + + $listfiles = file_get_contents($jsonfilepath); + + // Mark each files as to not be reset. + if (!empty($listfiles) && !self::$originaldatafilesjsonadded) { + $originaldatarootfiles = json_decode($listfiles); + // Keep the json file. Only drop_dataroot() should delete it. + $originaldatarootfiles[] = self::$originaldatafilesjson; + $utilclassname::$datarootskiponreset = array_merge($utilclassname::$datarootskiponreset, + $originaldatarootfiles); + self::$originaldatafilesjsonadded = true; + } + } + } + + /** + * Save the list of the original dataroot files into a json file. + */ + protected static function save_original_data_files() { + global $CFG; + + $jsonfilepath = self::get_dataroot() . '/' . self::$originaldatafilesjson; + + // Save the original dataroot files if not done (only executed the first time). + if (!file_exists($jsonfilepath)) { + + $listfiles = array(); + $listfiles['artefact/.'] = 'artefact/.'; + $listfiles['artefact/..'] = 'artefact/..'; + + $filedir = self::get_dataroot() . '/artefact'; + if (file_exists($filedir)) { + $directory = new RecursiveDirectoryIterator($filedir); + foreach (new RecursiveIteratorIterator($directory) as $file) { + if ($file->isDir()) { + $key = substr($file->getPath(), strlen(self::get_dataroot() . '/')); + } else { + $key = substr($file->getPathName(), strlen(self::get_dataroot() . '/')); + } + $listfiles[$key] = $key; + } + } + + // Save the file list in a JSON file. + $fp = fopen($jsonfilepath, 'w'); + fwrite($fp, json_encode(array_values($listfiles))); + fclose($fp); + } + } +} diff --git a/htdocs/testing/frameworks/behat/classes/BehatBase.php b/htdocs/testing/frameworks/behat/classes/BehatBase.php new file mode 100644 index 0000000000000000000000000000000000000000..8f04ab8c89589b494f1606ce006f3241a06b9a42 --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/BehatBase.php @@ -0,0 +1,689 @@ +getSession()->getPage()->find() could not + * be enough. + */ + const REDUCED_TIMEOUT = 2; + + /** + * The timeout for each Behat step (load page, wait for an element to load...). + */ + const TIMEOUT = 3; + + /** + * And extended timeout for specific cases. + */ + const EXTENDED_TIMEOUT = 10; + + /** + * The JS code to check that the page is ready. + */ + const PAGE_READY_JS = '(is_page_ready) && (document.readyState === "complete")'; + + /** + * Locates url, based on provided path. + * Override to provide custom routing mechanism. + * + * @see Behat\MinkExtension\Context\MinkContext + * @param string $path + * @return string + */ + protected function locate_path($path) { + $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/'; + return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path; + } + + /** + * Returns the first matching element. + * + * @link http://mink.behat.org/#traverse-the-page-selectors + * @param string $selector The selector type (css, xpath, named...) + * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator... + * @param Exception $exception Otherwise we throw exception with generic info + * @param NodeElement $node Spins around certain DOM node instead of the whole page + * @return NodeElement + */ + protected function find($selector, $locator, $exception = false, $node = false) { + + // Returns the first match. + $items = $this->find_all($selector, $locator, $exception, $node); + return count($items) ? reset($items) : null; + } + + /** + * Returns all matching elements. + * + * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method. + * + * @link http://mink.behat.org/#traverse-the-page-selectors + * @param string $selector The selector type (css, xpath, named...) + * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator... + * @param Exception $exception Otherwise we throw expcetion with generic info + * @param NodeElement $node Spins around certain DOM node instead of the whole page + * @return array NodeElements list + */ + protected function find_all($selector, $locator, $exception = false, $node = false) { + + // Generic info. + if (!$exception) { + + // With named selectors we can be more specific. + if ($selector == 'named') { + $exceptiontype = $locator[0]; + $exceptionlocator = $locator[1]; + + // If we are in a @javascript session all contents would be displayed as HTML characters. + if ($this->running_javascript()) { + $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES); + } + + } else { + $exceptiontype = $selector; + $exceptionlocator = $locator; + } + + $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator); + } + + $params = array('selector' => $selector, 'locator' => $locator); + // Pushing $node if required. + if ($node) { + $params['node'] = $node; + } + + // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception. + return $this->spin( + function($context, $args) { + + // If no DOM node provided look in all the page. + if (empty($args['node'])) { + return $context->getSession()->getPage()->findAll($args['selector'], $args['locator']); + } + + // For nodes contained in other nodes we can not use the basic named selectors + // as they include unions and they would look for matches in the DOM root. + $elementxpath = $context->getSession()->getSelectorsHandler()->selectorToXpath($args['selector'], $args['locator']); + + // Split the xpath in unions and prefix them with the container xpath. + $unions = explode('|', $elementxpath); + foreach ($unions as $key => $union) { + $union = trim($union); + + // We are in the container node. + if (strpos($union, '.') === 0) { + $union = substr($union, 1); + } else if (strpos($union, '/') !== 0) { + // Adding the path separator in case it is not there. + $union = '/' . $union; + } + $unions[$key] = $args['node']->getXpath() . $union; + } + + // We can not use usual Element::find() as it prefixes with DOM root. + return $context->getSession()->getDriver()->find(implode('|', $unions)); + }, + $params, + self::TIMEOUT, + $exception + ); + } + + /** + * Finds DOM nodes in the page using named selectors. + * + * The point of using this method instead of Mink ones is the spin + * method of BehatBase::find() that looks for the element until it + * is available or it timeouts, this avoids the false failures received + * when selenium tries to execute commands on elements that are not + * ready to be used. + * + * All steps that requires elements to be available before interact with + * them should use one of the find* methods. + * + * The methods calls requires a {'find_' . $elementtype}($locator) + * format, like find_link($locator), find_select($locator), + * find_button($locator)... + * + * @link http://mink.behat.org/#named-selectors + * @throws Exception + * @param string $name The name of the called method + * @param mixed $arguments + * @return NodeElement + */ + public function __call($name, $arguments) { + + if (substr($name, 0, 5) !== 'find_') { + throw new Exception('The "' . $name . '" method does not exist'); + } + + // Only the named selector identifier. + $cleanname = substr($name, 5); + + // All named selectors shares the interface. + if (count($arguments) !== 1) { + throw new Exception('The "' . $cleanname . '" named selector needs the locator as it\'s single argument'); + } + + // Redirecting execution to the find method with the specified selector. + // It will detect if it's pointing to an unexisting named selector. + return $this->find('named', + array( + $cleanname, + $this->getSession()->getSelectorsHandler()->xpathLiteral($arguments[0]) + ) + ); + } + + /** + * Escapes the double quote character. + * + * Double quote is the argument delimiter, it can be escaped + * with a backslash, but we auto-remove this backslashes + * before the step execution, this method is useful when using + * arguments as arguments for other steps. + * + * @param string $string + * @return string + */ + public function escape($string) { + return str_replace('"', '\"', $string); + } + + /** + * Executes the passed closure until returns true or time outs. + * + * In most cases the document.readyState === 'complete' will be enough, but sometimes JS + * requires more time to be completely loaded or an element to be visible or whatever is required to + * perform some action on an element; this method receives a closure which should contain the + * required statements to ensure the step definition actions and assertions have all their needs + * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the + * closure to the caller. + * + * The closures requirements to work well with this spin method are: + * - Must return false, null or '' if something goes wrong + * - Must return something != false if finishes as expected, this will be the (mixed) value + * returned by spin() + * + * The arguments of the closure are mixed, use $args depending on your needs. + * + * You can provide an exception to give more accurate feedback to tests writers, otherwise the + * closure exception will be used, but you must provide an exception if the closure does not throws + * an exception. + * + * @throws Exception If it timeouts without receiving something != false from the closure + * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method) + * @param mixed $args Arguments to pass to the closure + * @param int $timeout Timeout in seconds + * @param Exception $exception The exception to throw in case it time outs. + * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds. + * @return mixed The value returned by the closure + */ + protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) { + + // Using default timeout which is pretty high. + if (!$timeout) { + $timeout = self::TIMEOUT; + } + if ($microsleep) { + // Will sleep 1/10th of a second by default for self::TIMEOUT seconds. + $loops = $timeout * 10; + } else { + // Will sleep for self::TIMEOUT seconds. + $loops = $timeout; + } + + for ($i = 0; $i < $loops; $i++) { + // We catch the exception thrown by the step definition to execute it again. + try { + // We don't check with !== because most of the time closures will return + // direct Behat methods returns and we are not sure it will be always (bool)false + // if it just runs the behat method without returning anything $return == null. + if ($return = call_user_func($lambda, $this, $args)) { + return $return; + } + } catch (Exception $e) { + // We would use the first closure exception if no exception has been provided. + if (!$exception) { + $exception = $e; + } + // We wait until no exception is thrown or timeout expires. + continue; + } + + if ($microsleep) { + usleep(100000); + } else { + sleep(1); + } + } + + // Using Exception as is a development issue if no exception has been provided. + if (!$exception) { + $exception = new Exception('spin method requires an exception if the callback does not throw an exception'); + } + + // Throwing exception to the user. + throw $exception; + } + + /** + * Gets a NodeElement based on the locator and selector type received as argument from steps definitions. + * + * Use BehatBase::get_text_selector_node() for text-based selectors. + * + * @throws ElementNotFoundException Thrown by BehatBase::find + * @param string $selectortype + * @param string $element + * @return NodeElement + */ + protected function get_selected_node($selectortype, $element) { + + // Getting Mink selector and locator. + list($selector, $locator) = $this->transform_selector($selectortype, $element); + + // Returns the NodeElement. + return $this->find($selector, $locator); + } + + /** + * Gets a NodeElement based on the locator and selector type received as argument from steps definitions. + * + * @throws ElementNotFoundException Thrown by BehatBase::find + * @param string $selectortype + * @param string $element + * @return NodeElement + */ + protected function get_text_selector_node($selectortype, $element) { + + // Getting Mink selector and locator. + list($selector, $locator) = $this->transform_text_selector($selectortype, $element); + + // Returns the NodeElement. + return $this->find($selector, $locator); + } + + /** + * Gets the requested element inside the specified container. + * + * @throws ElementNotFoundException Thrown by BehatBase::find + * @param mixed $selectortype The element selector type. + * @param mixed $element The element locator. + * @param mixed $containerselectortype The container selector type. + * @param mixed $containerelement The container locator. + * @return NodeElement + */ + protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) { + + // Gets the container, it will always be text based. + $containernode = $this->get_text_selector_node($containerselectortype, $containerelement); + + list($selector, $locator) = $this->transform_selector($selectortype, $element); + + // Specific exception giving info about where can't we find the element. + $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"'; + $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg); + + // Looks for the requested node inside the container node. + return $this->find($selector, $locator, $exception, $containernode); + } + + /** + * Transforms from step definition's argument style to Mink format. + * + * Mink has 3 different selectors css, xpath and named, where named + * selectors includes link, button, field... to simplify and group multiple + * steps in one we use the same interface, considering all link, buttons... + * at the same level as css selectors and xpath; this method makes the + * conversion from the arguments received by the steps to the selectors and locators + * required to interact with Mink. + * + * @throws ExpectationException + * @param string $selectortype It can be css, xpath or any of the named selectors. + * @param string $element The locator (or string) we are looking for. + * @return array Contains the selector and the locator expected by Mink. + */ + protected function transform_selector($selectortype, $element) { + + // Here we don't know if an allowed text selector is being used. + $selectors = behat_selectors::get_allowed_selectors(); + if (!isset($selectors[$selectortype])) { + throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession()); + } + + return behat_selectors::get_behat_selector($selectortype, $element, $this->getSession()); + } + + /** + * Transforms from step definition's argument style to Mink format. + * + * Delegates all the process to BehatBase::transform_selector() checking + * the provided $selectortype. + * + * @throws ExpectationException + * @param string $selectortype It can be css, xpath or any of the named selectors. + * @param string $element The locator (or string) we are looking for. + * @return array Contains the selector and the locator expected by Mink. + */ + protected function transform_text_selector($selectortype, $element) { + + $selectors = behat_selectors::get_allowed_text_selectors(); + if (empty($selectors[$selectortype])) { + throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession()); + } + + return $this->transform_selector($selectortype, $element); + } + + /** + * Returns whether the scenario is running in a browser that can run Javascript or not. + * + * @return boolean + */ + protected function running_javascript() { + return get_class($this->getSession()->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver'; + } + + /** + * Spins around an element until it exists + * + * @throws ExpectationException + * @param string $element + * @param string $selectortype + * @return void + */ + protected function ensure_element_exists($element, $selectortype) { + + // Getting the behat selector & locator. + list($selector, $locator) = $this->transform_selector($selectortype, $element); + + // Exception if it timesout and the element is still there. + $msg = 'The "' . $element . '" element does not exist and should exist'; + $exception = new ExpectationException($msg, $this->getSession()); + + // It will stop spinning once the find() method returns true. + $this->spin( + function($context, $args) { + // We don't use BehatBase::find as it is already spinning. + if ($context->getSession()->getPage()->find($args['selector'], $args['locator'])) { + return true; + } + return false; + }, + array('selector' => $selector, 'locator' => $locator), + self::EXTENDED_TIMEOUT, + $exception, + true + ); + + } + + /** + * Spins until the element does not exist + * + * @throws ExpectationException + * @param string $element + * @param string $selectortype + * @return void + */ + protected function ensure_element_does_not_exist($element, $selectortype) { + + // Getting the behat selector & locator. + list($selector, $locator) = $this->transform_selector($selectortype, $element); + + // Exception if it timesout and the element is still there. + $msg = 'The "' . $element . '" element exists and should not exist'; + $exception = new ExpectationException($msg, $this->getSession()); + + // It will stop spinning once the find() method returns false. + $this->spin( + function($context, $args) { + // We don't use BehatBase::find() as we are already spinning. + if (!$context->getSession()->getPage()->find($args['selector'], $args['locator'])) { + return true; + } + return false; + }, + array('selector' => $selector, 'locator' => $locator), + self::EXTENDED_TIMEOUT, + $exception, + true + ); + } + + /** + * Ensures that the provided node is visible and we can interact with it. + * + * @throws ExpectationException + * @param NodeElement $node + * @return void Throws an exception if it times out without the element being visible + */ + protected function ensure_node_is_visible($node) { + + if (!$this->running_javascript()) { + return; + } + + // Exception if it timesout and the element is still there. + $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible'; + $exception = new ExpectationException($msg, $this->getSession()); + + // It will stop spinning once the isVisible() method returns true. + $this->spin( + function($context, $args) { + if ($args->isVisible()) { + return true; + } + return false; + }, + $node, + self::EXTENDED_TIMEOUT, + $exception, + true + ); + } + + /** + * Ensures that the provided element is visible and we can interact with it. + * + * Returns the node in case other actions are interested in using it. + * + * @throws ExpectationException + * @param string $element + * @param string $selectortype + * @return NodeElement Throws an exception if it times out without being visible + */ + protected function ensure_element_is_visible($element, $selectortype) { + + if (!$this->running_javascript()) { + return; + } + + $node = $this->get_selected_node($selectortype, $element); + $this->ensure_node_is_visible($node); + + return $node; + } + + /** + * Ensures that all the page's editors are loaded. + * + * This method is expensive as it waits for .mceEditor CSS + * so use with caution and only where there will be editors. + * + * @throws ElementNotFoundException + * @throws ExpectationException + * @return void + */ + protected function ensure_editors_are_loaded() { + + if (!$this->running_javascript()) { + return; + } + + // If there are no editors we don't need to wait. + try { + $this->find('css', '.mceEditor'); + } catch (ElementNotFoundException $e) { + return; + } + + // Exception if it timesout and the element is not appearing. + $msg = 'The editors are not completely loaded'; + $exception = new ExpectationException($msg, $this->getSession()); + + // Here we know that there are .mceEditor editors in the page and we will + // probably need to interact with them, if we use tinyMCE JS var before + // it exists it will throw an exception and we want to catch it until all + // the page's editors are ready to interact with them. + $this->spin( + function($context) { + + // It may return 0 if tinyMCE is loaded but not the instances, so we just loop again. + $neditors = $context->getSession()->evaluateScript('return tinyMCE.editors.length;'); + if ($neditors == 0) { + return false; + } + + // It may be there but not ready. + $iframeready = $context->getSession()->evaluateScript(' + var readyeditors = new Array; + for (editorid in tinyMCE.editors) { + if (tinyMCE.editors[editorid].getDoc().readyState === "complete") { + readyeditors[editorid] = editorid; + } + } + if (tinyMCE.editors.length === readyeditors.length) { + return "complete"; + } + return ""; + '); + + // Now we know that the editors are there. + if ($iframeready) { + return true; + } + + // Loop again if it is not ready. + return false; + }, + false, + self::EXTENDED_TIMEOUT, + $exception, + true + ); + } + + /** + * Change browser window size. + * - small: 640x480 + * - medium: 1024x768 + * - large: 2560x1600 + * + * @param string $windowsize size of window. + * @throws ExpectationException + */ + protected function resize_window($windowsize) { + // Non JS don't support resize window. + if (!$this->running_javascript()) { + return; + } + + switch ($windowsize) { + case "small": + $width = 640; + $height = 480; + break; + case "medium": + $width = 1024; + $height = 768; + break; + case "large": + $width = 2560; + $height = 1600; + break; + default: + preg_match('/^(\d+x\d+)$/', $windowsize, $matches); + if (empty($matches) || (count($matches) != 2)) { + throw new ExpectationException("Invalid screen size, can't resize", $this->getSession()); + } + $size = explode('x', $windowsize); + $width = (int) $size[0]; + $height = (int) $size[1]; + } + $this->getSession()->getDriver()->resizeWindow($width, $height); + } + + /** + * Waits for all the JS to be loaded. + * + * @throws Exception + * @throws NoSuchWindow + * @throws UnknownError + * @return bool True or false depending whether all the JS is loaded or not. + */ + protected function wait_for_pending_js() { + + for ($i = 0; $i < self::EXTENDED_TIMEOUT * 10; $i++) { + $ready = false; + try { + $jscode = 'return ' . self::PAGE_READY_JS; + $ready = $this->getSession()->evaluateScript($jscode); + } catch (NoSuchWindow $nsw) { + // We catch an exception here, in case we just closed the window we were interacting with. + // No javascript is running if there is no window right? + $ready = true; + } catch (UnknownError $e) { + $ready = true; + } + + // If there are no pending JS we stop waiting. + if ($ready) { + return true; + } + + // 0.1 seconds. + usleep(100000); + } + return false; + } + +} diff --git a/htdocs/testing/frameworks/behat/classes/BehatCommand.php b/htdocs/testing/frameworks/behat/classes/BehatCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..1fa9a91ab47eff332d3244f7f3c999e2248f389a --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/BehatCommand.php @@ -0,0 +1,190 @@ +behat_dataroot . '/behat'; + + if (!is_dir($behatdir)) { + if (!mkdir($behatdir, $CFG->directorypermissions, true)) { + behat_error(BEHAT_EXITCODE_PERMISSIONS, 'Directory ' . $behatdir . ' can not be created'); + } + } + + if (!is_writable($behatdir)) { + behat_error(BEHAT_EXITCODE_PERMISSIONS, 'Directory ' . $behatdir . ' is not writable'); + } + + return $behatdir; + } + + /** + * Returns the executable path + * + * Note: Mahara does not support running behat on Windows + * @param bool $custombyterm If the provided command should depend on the terminal where it runs + * @return string + */ + public final static function get_behat_command($custombyterm = false) { + + $separator = DIRECTORY_SEPARATOR; + $exec = 'behat'; + + return 'vendor' . $separator . 'bin' . $separator . $exec; + } + + /** + * Runs behat command with provided options + * + * Execution continues when the process finishes + * + * @param string $options Defaults to '' so tests would be executed + * @return array CLI command outputs [0] => string, [1] => integer + */ + public final static function run($options = '') { + global $CFG; + + $currentcwd = getcwd(); + chdir($CFG->docroot); + exec(self::get_behat_command() . ' ' . $options, $output, $code); + chdir($currentcwd); + + return array($output, $code); + } + + /** + * Checks if behat is set up and working + * + * Notifies failures both from CLI and web interface. + * + * It checks behat dependencies have been installed and runs + * the behat help command to ensure it works as expected + * + * @return int Error code or 0 if all ok + */ + public static function behat_setup_problem() { + global $CFG; + + // Mahara setting. + if (!self::are_behat_dependencies_installed()) { + + // Returning composer error code to avoid conflicts with behat and mahara error codes. + self::output_msg(get_string('errorcomposer', 'behat')); + return BEHAT_EXITCODE_COMPOSER; + } + + // Behat test command. + list($output, $code) = self::run(' --help'); + + if ($code != 0) { + + // Returning composer error code to avoid conflicts with behat and mahara error codes. + self::output_msg(get_string('errorbehatcommand', 'behat', self::get_behat_command())); + return BEHAT_EXITCODE_COMPOSER; + } + + // No empty values. + if (empty($CFG->behat_dataroot) || empty($CFG->behat_dbprefix) || empty($CFG->behat_wwwroot)) { + self::output_msg(get_string('errorsetconfig', 'behat')); + return BEHAT_EXITCODE_CONFIG; + + } + + // Not repeated values. + // We only need to check this when the behat site is not running as + // at this point, when it is running, all $CFG->behat_* vars have + // already been copied to $CFG->dataroot, $CFG->dbprefix and $CFG->wwwroot. + if (!defined('BEHAT_SITE_RUNNING') && + ($CFG->behat_dbprefix == $CFG->dbprefix || + $CFG->behat_dataroot == $CFG->dataroot || + $CFG->behat_wwwroot == $CFG->wwwroot || + (!empty($CFG->phpunit_dbprefix) && $CFG->phpunit_dbprefix == $CFG->behat_dbprefix) || + (!empty($CFG->phpunit_dataroot) && $CFG->phpunit_dataroot == $CFG->behat_dataroot) + )) { + self::output_msg(get_string('erroruniqueconfig', 'behat')); + return BEHAT_EXITCODE_CONFIG; + } + + // Checking behat dataroot existence otherwise echo about admin/tool/behat/cli/init.php. + if (!empty($CFG->behat_dataroot)) { + $CFG->behat_dataroot = realpath($CFG->behat_dataroot); + } + if (empty($CFG->behat_dataroot) || !is_dir($CFG->behat_dataroot) || !is_writable($CFG->behat_dataroot)) { + self::output_msg(get_string('errordataroot', 'behat')); + return BEHAT_EXITCODE_CONFIG; + } + + return 0; + } + + /** + * Has the site installed composer with --dev option + * @return bool + */ + public static function are_behat_dependencies_installed() { + if (!is_dir(dirname(dirname(dirname(dirname(__DIR__)))) . '/vendor/behat')) { + return false; + } + return true; + } + + /** + * Outputs a message. + * + * Used in CLI + web UI methods. Stops the + * execution in web. + * + * @param string $msg + * @return void + */ + protected static function output_msg($msg) { + global $CFG; + + // If we are using the web interface we want pretty messages. + if (!CLI) { + + echo($msg); + + // Stopping execution. + exit(1); + + } else { + + // We continue execution after this. + $clibehaterrorstr = "Ensure you set \$CFG->behat_* vars in config.php " . + "and you ran testing/frameworks/behat/cli/init.php.\n" . + "More info in " . self::DOCS_URL . "#Installation\n\n"; + + echo 'Error: ' . $msg . "\n\n" . $clibehaterrorstr; + } + } + +} diff --git a/htdocs/testing/frameworks/behat/classes/BehatConfigManager.php b/htdocs/testing/frameworks/behat/classes/BehatConfigManager.php new file mode 100644 index 0000000000000000000000000000000000000000..7f0396df655344e8d0cb2e249613c73350c20e5a --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/BehatConfigManager.php @@ -0,0 +1,295 @@ +docroot one. + * + * @param string $plugin Restricts the obtained steps definitions to the specified plugin + * @param string $testsrunner If the config file will be used to run tests + * @return void + */ + public static function update_config_file($plugin = '', $testsrunner = true) { + global $CFG; + + // Behat must have a separate behat.yml to have access to the whole set of features and steps definitions. + if ($testsrunner === true) { + $configfilepath = BehatCommand::get_behat_dir() . '/behat.yml'; + } else { + // Alternative for steps definitions filtering, one for each user. + $configfilepath = self::get_steps_list_config_filepath(); + } + + // Gets all the plugins with features. + $features = array(); + $plugins = TestsFinder::get_plugins_with_tests('features'); + if ($plugins) { + foreach ($plugins as $pluginname => $path) { + $path = self::clean_path($path) . self::get_behat_tests_path(); + if (empty($featurespaths[$path]) && file_exists($path)) { + + // Standarizes separator (some dirs. comes with OS-dependant separator). + $uniquekey = str_replace('\\', '/', $path); + $featurespaths[$uniquekey] = $path; + } + } + $features = array_values($featurespaths); + } + + // Optionally include features from additional directories. + if (!empty($CFG->behat_additionalfeatures)) { + $features = array_merge($features, array_map("realpath", $CFG->behat_additionalfeatures)); + } + + // Gets all the plugins with steps definitions. + $stepsdefinitions = array(); + $steps = self::get_plugins_steps_definitions(); + if ($steps) { + foreach ($steps as $key => $filepath) { + if ($plugin === '' || $plugin === $key) { + $stepsdefinitions[$key] = $filepath; + } + } + } + + // We don't want the deprecated steps definitions here. + if (!$testsrunner) { + unset($stepsdefinitions['behat_deprecated']); + } + + // Behat config file specifing the main context class, + // the required Behat extensions and Mahara test wwwroot. + $contents = self::get_config_file_contents($features, $stepsdefinitions); + + // Stores the file. + if (!file_put_contents($configfilepath, $contents)) { + behat_error(BEHAT_EXITCODE_PERMISSIONS, 'File ' . $configfilepath . ' can not be created'); + } + + } + + /** + * Gets the list of Mahara steps definitions + * + * Class name as a key and the filepath as value + * + * Externalized from update_config_file() to use + * it from the steps definitions web interface + * + * @return array + */ + public static function get_plugins_steps_definitions() { + + $plugins = TestsFinder::get_plugins_with_tests('stepsdefinitions'); + if (!$plugins) { + return false; + } + + $stepsdefinitions = array(); + foreach ($plugins as $pluginname => $pluginpath) { + $pluginpath = self::clean_path($pluginpath); + + if (!file_exists($pluginpath . self::get_behat_tests_path())) { + continue; + } + $diriterator = new DirectoryIterator($pluginpath . self::get_behat_tests_path()); + $regite = new RegexIterator($diriterator, '|Behat.*\.php$|'); + + // All Behat*.php inside BehatConfigManager::get_behat_tests_path() are added as steps definitions files. + foreach ($regite as $file) { + $key = $file->getBasename('.php'); + $stepsdefinitions[$key] = $file->getPathname(); + } + } + + return $stepsdefinitions; + } + + /** + * Returns the behat config file path used by the steps definition list + * + * @return string + */ + public static function get_steps_list_config_filepath() { + global $USER; + + // We don't cygwin-it as it is called using exec() which uses cmd.exe. + $userdir = behat_command::get_behat_dir() . '/users/' . $USER->id; + make_writable_directory($userdir); + + return $userdir . '/behat.yml'; + } + + /** + * Returns the behat config file path used by the behat cli command. + * + * @return string + */ + public static function get_behat_cli_config_filepath() { + global $CFG; + + $command = $CFG->behat_dataroot . DIRECTORY_SEPARATOR . 'behat' . DIRECTORY_SEPARATOR . 'behat.yml'; + + return $command; + } + + /** + * Behat config file specifing the main context class, + * the required Behat extensions and Mahara test wwwroot. + * + * @param array $features The system feature files + * @param array $stepsdefinitions The system steps definitions + * @return string + */ + protected static function get_config_file_contents($features, $stepsdefinitions) { + global $CFG; + + // We require here when we are sure behat dependencies are available. + require_once($CFG->docroot . '/vendor/autoload.php'); + + // It is possible that it has no value as we don't require a full behat setup to list the step definitions. + if (empty($CFG->behat_wwwroot)) { + $CFG->behat_wwwroot = 'http://itwillnotbeused.com'; + } + + $basedir = $CFG->docroot . 'testing' . DIRECTORY_SEPARATOR . 'frameworks' . DIRECTORY_SEPARATOR . 'behat'; + $config = array( + 'default' => array( + 'paths' => array( + 'features' => $basedir . DIRECTORY_SEPARATOR . 'features', + 'bootstrap' => $basedir . DIRECTORY_SEPARATOR . 'features' . DIRECTORY_SEPARATOR . 'bootstrap', + ), + 'context' => array( + 'class' => 'BehatMaharaInitContext' + ), + 'extensions' => array( + 'Behat\MinkExtension\Extension' => array( + 'base_url' => $CFG->behat_wwwroot, + 'goutte' => null, + 'selenium2' => null + ), + $basedir . DIRECTORY_SEPARATOR . 'features' . DIRECTORY_SEPARATOR . 'extensions' . DIRECTORY_SEPARATOR . 'MaharaExtension.php' => array( + 'formatters' => array( + 'mahara_progress' => 'MaharaProgressFormatter' + ), + 'features' => $features, + 'steps_definitions' => $stepsdefinitions + ) + ), + 'formatter' => array( + 'name' => 'mahara_progress' + ) + ) + ); + + // In case user defined overrides respect them over our default ones. + if (!empty($CFG->behat_config)) { + $config = self::merge_config($config, $CFG->behat_config); + } + + return Symfony\Component\Yaml\Yaml::dump($config, 10, 2); + } + + /** + * Overrides default config with local config values + * + * array_merge does not merge completely the array's values + * + * @param mixed $config The node of the default config + * @param mixed $localconfig The node of the local config + * @return mixed The merge result + */ + protected static function merge_config($config, $localconfig) { + + if (!is_array($config) && !is_array($localconfig)) { + return $localconfig; + } + + // Local overrides also deeper default values. + if (is_array($config) && !is_array($localconfig)) { + return $localconfig; + } + + foreach ($localconfig as $key => $value) { + + // If defaults are not as deep as local values let locals override. + if (!is_array($config)) { + unset($config); + } + + // Add the param if it doesn't exists or merge branches. + if (empty($config[$key])) { + $config[$key] = $value; + } else { + $config[$key] = self::merge_config($config[$key], $localconfig[$key]); + } + } + + return $config; + } + + /** + * Cleans the path returned by get_plugins_with_tests() to standarize it + * + * @see TestsFinder::get_all_directories_with_tests() it returns the path including /tests/ + * @param string $path + * @return string The string without the last /tests part + */ + protected final static function clean_path($path) { + + $path = rtrim($path, DIRECTORY_SEPARATOR); + + $parttoremove = DIRECTORY_SEPARATOR . 'tests'; + + $substr = substr($path, strlen($path) - strlen($parttoremove)); + if ($substr == $parttoremove) { + $path = substr($path, 0, strlen($path) - strlen($parttoremove)); + } + + return rtrim($path, DIRECTORY_SEPARATOR); + } + + /** + * The relative path where plugins stores their behat tests + * + * @return string + */ + protected final static function get_behat_tests_path() { + return DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'behat'; + } + +} diff --git a/htdocs/testing/frameworks/behat/classes/BehatContextHelper.php b/htdocs/testing/frameworks/behat/classes/BehatContextHelper.php new file mode 100644 index 0000000000000000000000000000000000000000..9d7700e5dc5133c387a20dcb31b97f07f4a53f46 --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/BehatContextHelper.php @@ -0,0 +1,93 @@ + $session)); + self::$mink->setDefaultSessionName('mink'); + } + + /** + * Gets the required context. + * + * Getting a context you get access to all the steps + * that uses direct API calls; steps returning step chains + * can not be executed like this. + * + * @throws coding_exception + * @param string $classname Context identifier (the class name). + * @return behat_base + */ + public static function get($classname) { + + if (!self::init_context($classname)) { + throw Exception('The required "' . $classname . '" class does not exist'); + } + + return self::$contexts[$classname]; + } + + /** + * Initializes the required context. + * + * @throws Exception + * @param string $classname + * @return bool + */ + protected static function init_context($classname) { + + if (!empty(self::$contexts[$classname])) { + return true; + } + + if (!class_exists($classname)) { + return false; + } + + $instance = new $classname(); + $instance->setMink(self::$mink); + + self::$contexts[$classname] = $instance; + + return true; + } + +} diff --git a/htdocs/testing/frameworks/behat/classes/BehatGeneral.php b/htdocs/testing/frameworks/behat/classes/BehatGeneral.php new file mode 100644 index 0000000000000000000000000000000000000000..3959eb76f056074d4f0ae69d39248bc3a10b3fb3 --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/BehatGeneral.php @@ -0,0 +1,848 @@ +getSession()->visit($this->locate_path('/')); + } + + /** + * Reloads the current page. + * + * @Given /^I reload the page$/ + */ + public function I_reload_the_page() { + $this->getSession()->reload(); + } + + /** + * Follows the page redirection. Use this step after any action that shows a message and waits for a redirection + * + * @Given /^I wait to be redirected$/ + */ + public function i_wait_to_be_redirected() { + + // Xpath and processes based on core_renderer::redirect_message(), core_renderer::$metarefreshtag and + // moodle_page::$periodicrefreshdelay possible values. + if (!$metarefresh = $this->getSession()->getPage()->find('xpath', "//head/descendant::meta[@http-equiv='refresh']")) { + // We don't fail the scenario if no redirection with message is found to avoid race condition false failures. + return true; + } + + // Wrapped in try & catch in case the redirection has already been executed. + try { + $content = $metarefresh->getAttribute('content'); + } catch (NoSuchElement $e) { + return true; + } catch (StaleElementReference $e) { + return true; + } + + // Getting the refresh time and the url if present. + if (strstr($content, 'url') != false) { + + list($waittime, $url) = explode(';', $content); + + // Cleaning the URL value. + $url = trim(substr($url, strpos($url, 'http'))); + + } else { + // Just wait then. + $waittime = $content; + } + + + // Wait until the URL change is executed. + if ($this->running_javascript()) { + $this->getSession()->wait($waittime * 1000, false); + + } else if (!empty($url)) { + // We redirect directly as we can not wait for an automatic redirection. + $this->getSession()->getDriver()->getClient()->request('get', $url); + + } else { + // Reload the page if no URL was provided. + $this->getSession()->getDriver()->reload(); + } + } + + /** + * Switches to the specified iframe. + * + * @Given /^I switch to "(?P(?:[^"]|\\")*)" iframe$/ + * @param string $iframename + */ + public function switch_to_iframe($iframename) { + + // We spin to give time to the iframe to be loaded. + // Using extended timeout as we don't know about which + // kind of iframe will be loaded. + $this->spin( + function($context, $iframename) { + $context->getSession()->switchToIFrame($iframename); + + // If no exception we are done. + return true; + }, + $iframename, + self::EXTENDED_TIMEOUT + ); + } + + /** + * Switches to the main Moodle frame. + * + * @Given /^I switch to the main frame$/ + */ + public function switch_to_the_main_frame() { + $this->getSession()->switchToIFrame(); + } + + /** + * Switches to the specified window. Useful when interacting with popup windows. + * + * @Given /^I switch to "(?P(?:[^"]|\\")*)" window$/ + * @param string $windowname + */ + public function switch_to_window($windowname) { + $this->getSession()->switchToWindow($windowname); + } + + /** + * Switches to the main Moodle window. Useful when you finish interacting with popup windows. + * + * @Given /^I switch to the main window$/ + */ + public function switch_to_the_main_window() { + $this->getSession()->switchToWindow(); + } + + /** + * Accepts the currently displayed alert dialog. This step does not work in all the browsers, consider it experimental. + * @Given /^I accept the currently displayed dialog$/ + */ + public function accept_currently_displayed_alert_dialog() { + $this->getSession()->getDriver()->getWebDriverSession()->accept_alert(); + } + + /** + * Clicks link with specified id|title|alt|text. + * + * @When /^I follow "(?P(?:[^"]|\\")*)"$/ + * @throws ElementNotFoundException Thrown by BehatBase::find + * @param string $link + */ + public function click_link($link) { + + $linknode = $this->find_link($link); + $this->ensure_node_is_visible($linknode); + $linknode->click(); + } + + /** + * Waits X seconds. Required after an action that requires data from an AJAX request. + * + * @Then /^I wait "(?P\d+)" seconds$/ + * @param int $seconds + */ + public function i_wait_seconds($seconds) { + + if (!$this->running_javascript()) { + throw new DriverException('Waits are disabled in scenarios without Javascript support'); + } + + $this->getSession()->wait($seconds * 1000, false); + } + + /** + * Waits until the page is completely loaded. This step is auto-executed after every step. + * + * @Given /^I wait until the page is ready$/ + */ + public function wait_until_the_page_is_ready() { + + if (!$this->running_javascript()) { + throw new DriverException('Waits are disabled in scenarios without Javascript support'); + } + + $this->getSession()->wait(self::TIMEOUT * 1000, self::PAGE_READY_JS); + } + + /** + * Waits until the provided element selector exists in the DOM + * + * Using the protected method as this method will be usually + * called by other methods which are not returning a set of + * steps and performs the actions directly, so it would not + * be executed if it returns another step. + + * @Given /^I wait until "(?P(?:[^"]|\\")*)" "(?P[^"]*)" exists$/ + * @param string $element + * @param string $selector + * @return void + */ + public function wait_until_exists($element, $selectortype) { + $this->ensure_element_exists($element, $selectortype); + } + + /** + * Waits until the provided element does not exist in the DOM + * + * Using the protected method as this method will be usually + * called by other methods which are not returning a set of + * steps and performs the actions directly, so it would not + * be executed if it returns another step. + + * @Given /^I wait until "(?P(?:[^"]|\\")*)" "(?P[^"]*)" does not exist$/ + * @param string $element + * @param string $selector + * @return void + */ + public function wait_until_does_not_exists($element, $selectortype) { + $this->ensure_element_does_not_exist($element, $selectortype); + } + + /** + * Generic mouse over action. Mouse over a element of the specified type. + * + * @When /^I hover "(?P(?:[^"]|\\")*)" "(?P[^"]*)"$/ + * @param string $element Element we look for + * @param string $selectortype The type of what we look for + */ + public function i_hover($element, $selectortype) { + + // Gets the node based on the requested selector type and locator. + $node = $this->get_selected_node($selectortype, $element); + $node->mouseOver(); + } + + /** + * Generic click action. Click on the element of the specified type. + * + * @When /^I click on "(?P(?:[^"]|\\")*)" "(?P[^"]*)"$/ + * @param string $element Element we look for + * @param string $selectortype The type of what we look for + */ + public function i_click_on($element, $selectortype) { + + // Gets the node based on the requested selector type and locator. + $node = $this->get_selected_node($selectortype, $element); + $this->ensure_node_is_visible($node); + $node->click(); + } + + /** + * Click on the element of the specified type which is located inside the second element. + * + * @When /^I click on "(?P(?:[^"]|\\")*)" "(?P[^"]*)" in the "(?P(?:[^"]|\\")*)" "(?P[^"]*)"$/ + * @param string $element Element we look for + * @param string $selectortype The type of what we look for + * @param string $nodeelement Element we look in + * @param string $nodeselectortype The type of selector where we look in + */ + public function i_click_on_in_the($element, $selectortype, $nodeelement, $nodeselectortype) { + + $node = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement); + $this->ensure_node_is_visible($node); + $node->click(); + } + + /** + * Click on the specified element inside a table row containing the specified text. + * + * @Given /^I click on "(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)" in the "(?P(?:[^"]|\\")*)" table row$/ + * @throws ElementNotFoundException + * @param string $element Element we look for + * @param string $selectortype The type of what we look for + * @param string $tablerowtext The table row text + */ + public function i_click_on_in_the_table_row($element, $selectortype, $tablerowtext) { + + // The table row container. + $nocontainerexception = new ElementNotFoundException($this->getSession(), '"' . $tablerowtext . '" row text '); + $tablerowtext = $this->getSession()->getSelectorsHandler()->xpathLiteral($tablerowtext); + $rownode = $this->find('xpath', "//tr[contains(., $tablerowtext)]", $nocontainerexception); + + // Looking for the element DOM node inside the specified row. + list($selector, $locator) = $this->transform_selector($selectortype, $element); + $elementnode = $this->find($selector, $locator, false, $rownode); + $this->ensure_node_is_visible($elementnode); + $elementnode->click(); + } + + /** + * Drags and drops the specified element to the specified container. This step does not work in all the browsers, consider it experimental. + * + * The steps definitions calling this step as part of them should + * manage the wait times by themselves as the times and when the + * waits should be done depends on what is being dragged & dropper. + * + * @Given /^I drag "(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)" and I drop it in "(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)"$/ + * @param string $element + * @param string $selectortype + * @param string $containerelement + * @param string $containerselectortype + */ + public function i_drag_and_i_drop_it_in($element, $selectortype, $containerelement, $containerselectortype) { + + list($sourceselector, $sourcelocator) = $this->transform_selector($selectortype, $element); + $sourcexpath = $this->getSession()->getSelectorsHandler()->selectorToXpath($sourceselector, $sourcelocator); + + list($containerselector, $containerlocator) = $this->transform_selector($containerselectortype, $containerelement); + $destinationxpath = $this->getSession()->getSelectorsHandler()->selectorToXpath($containerselector, $containerlocator); + + $this->getSession()->getDriver()->dragTo($sourcexpath, $destinationxpath); + } + + /** + * Checks, that the specified element is visible. Only available in tests using Javascript. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)" should be visible$/ + * @throws ElementNotFoundException + * @throws ExpectationException + * @throws DriverException + * @param string $element + * @param string $selectortype + * @return void + */ + public function should_be_visible($element, $selectortype) { + + if (!$this->running_javascript()) { + throw new DriverException('Visible checks are disabled in scenarios without Javascript support'); + } + + $node = $this->get_selected_node($selectortype, $element); + if (!$node->isVisible()) { + throw new ExpectationException('"' . $element . '" "' . $selectortype . '" is not visible', $this->getSession()); + } + } + + /** + * Checks, that the specified element is not visible. Only available in tests using Javascript. + * + * As a "not" method, it's performance is not specially good as we should ensure that the element + * have time to appear. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)" should not be visible$/ + * @throws ElementNotFoundException + * @throws ExpectationException + * @param string $element + * @param string $selectortype + * @return void + */ + public function should_not_be_visible($element, $selectortype) { + + try { + $this->should_be_visible($element, $selectortype); + throw new ExpectationException('"' . $element . '" "' . $selectortype . '" is visible', $this->getSession()); + } catch (ExpectationException $e) { + // All as expected. + } + } + + /** + * Checks, that the specified element is visible inside the specified container. Only available in tests using Javascript. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P[^"]*)" in the "(?P(?:[^"]|\\")*)" "(?P[^"]*)" should be visible$/ + * @throws ElementNotFoundException + * @throws DriverException + * @throws ExpectationException + * @param string $element Element we look for + * @param string $selectortype The type of what we look for + * @param string $nodeelement Element we look in + * @param string $nodeselectortype The type of selector where we look in + */ + public function in_the_should_be_visible($element, $selectortype, $nodeelement, $nodeselectortype) { + + if (!$this->running_javascript()) { + throw new DriverException('Visible checks are disabled in scenarios without Javascript support'); + } + + $node = $this->get_node_in_container($selectortype, $element, $nodeselectortype, $nodeelement); + if (!$node->isVisible()) { + throw new ExpectationException( + '"' . $element . '" "' . $selectortype . '" in the "' . $nodeelement . '" "' . $nodeselectortype . '" is not visible', + $this->getSession() + ); + } + } + + /** + * Checks, that the specified element is not visible inside the specified container. Only available in tests using Javascript. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P[^"]*)" in the "(?P(?:[^"]|\\")*)" "(?P[^"]*)" should not be visible$/ + * @throws ElementNotFoundException + * @throws ExpectationException + * @param string $element Element we look for + * @param string $selectortype The type of what we look for + * @param string $nodeelement Element we look in + * @param string $nodeselectortype The type of selector where we look in + */ + public function in_the_should_not_be_visible($element, $selectortype, $nodeelement, $nodeselectortype) { + + try { + $this->in_the_should_be_visible($element, $selectortype, $nodeelement, $nodeselectortype); + throw new ExpectationException( + '"' . $element . '" "' . $selectortype . '" in the "' . $nodeelement . '" "' . $nodeselectortype . '" is visible', + $this->getSession() + ); + } catch (ExpectationException $e) { + // All as expected. + } + } + + /** + * Checks, that page contains specified text. It also checks if the text is visible when running Javascript tests. + * + * @Then /^I should see "(?P(?:[^"]|\\")*)"$/ + * @throws ExpectationException + * @param string $text + */ + public function assert_page_contains_text($text) { + + // Looking for all the matching nodes without any other descendant matching the + // same xpath (we are using contains(., ....). + $xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($text); + $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" . + "[count(descendant::*[contains(., $xpathliteral)]) = 0]"; + + try { + $nodes = $this->find_all('xpath', $xpath); + } catch (ElementNotFoundException $e) { + throw new ExpectationException('"' . $text . '" text was not found in the page', $this->getSession()); + } + + // If we are not running javascript we have enough with the + // element existing as we can't check if it is visible. + if (!$this->running_javascript()) { + return; + } + + // We spin as we don't have enough checking that the element is there, we + // should also ensure that the element is visible. + $this->spin( + function($context, $args) { + + foreach ($args['nodes'] as $node) { + if ($node->isVisible()) { + return true; + } + } + + // If non of the nodes is visible we loop again. + throw new ExpectationException('"' . $args['text'] . '" text was found but was not visible', $context->getSession()); + }, + array('nodes' => $nodes, 'text' => $text) + ); + + } + + /** + * Checks, that page doesn't contain specified text. When running Javascript tests it also considers that texts may be hidden. + * + * @Then /^I should not see "(?P(?:[^"]|\\")*)"$/ + * @throws ExpectationException + * @param string $text + */ + public function assert_page_not_contains_text($text) { + + // Looking for all the matching nodes without any other descendant matching the + // same xpath (we are using contains(., ....). + $xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($text); + $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" . + "[count(descendant::*[contains(., $xpathliteral)]) = 0]"; + + // We should wait a while to ensure that the page is not still loading elements. + // Giving preference to the reliability of the results rather than to the performance. + try { + $nodes = $this->find_all('xpath', $xpath); + } catch (ElementNotFoundException $e) { + // All ok. + return; + } + + // If we are not running javascript we have enough with the + // element existing as we can't check if it is hidden. + if (!$this->running_javascript()) { + throw new ExpectationException('"' . $text . '" text was found in the page', $this->getSession()); + } + + // If the element is there we should be sure that it is not visible. + $this->spin( + function($context, $args) { + + foreach ($args['nodes'] as $node) { + if ($node->isVisible()) { + throw new ExpectationException('"' . $args['text'] . '" text was found in the page', $context->getSession()); + } + } + + // If non of the found nodes is visible we consider that the text is not visible. + return true; + }, + array('nodes' => $nodes, 'text' => $text) + ); + + } + + /** + * Checks, that the specified element contains the specified text. When running Javascript tests it also considers that texts may be hidden. + * + * @Then /^I should see "(?P(?:[^"]|\\")*)" in the "(?P(?:[^"]|\\")*)" "(?P[^"]*)"$/ + * @throws ElementNotFoundException + * @throws ExpectationException + * @param string $text + * @param string $element Element we look in. + * @param string $selectortype The type of element where we are looking in. + */ + public function assert_element_contains_text($text, $element, $selectortype) { + + // Getting the container where the text should be found. + $container = $this->get_selected_node($selectortype, $element); + + // Looking for all the matching nodes without any other descendant matching the + // same xpath (we are using contains(., ....). + $xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($text); + $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" . + "[count(descendant::*[contains(., $xpathliteral)]) = 0]"; + + // Wait until it finds the text inside the container, otherwise custom exception. + try { + $nodes = $this->find_all('xpath', $xpath, false, $container); + } catch (ElementNotFoundException $e) { + throw new ExpectationException('"' . $text . '" text was not found in the "' . $element . '" element', $this->getSession()); + } + + // If we are not running javascript we have enough with the + // element existing as we can't check if it is visible. + if (!$this->running_javascript()) { + return; + } + + // We also check the element visibility when running JS tests. + $this->spin( + function($context, $args) { + + foreach ($args['nodes'] as $node) { + if ($node->isVisible()) { + return true; + } + } + + throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element but was not visible', $context->getSession()); + }, + array('nodes' => $nodes, 'text' => $text, 'element' => $element) + ); + } + + /** + * Checks, that the specified element does not contain the specified text. When running Javascript tests it also considers that texts may be hidden. + * + * @Then /^I should not see "(?P(?:[^"]|\\")*)" in the "(?P(?:[^"]|\\")*)" "(?P[^"]*)"$/ + * @throws ElementNotFoundException + * @throws ExpectationException + * @param string $text + * @param string $element Element we look in. + * @param string $selectortype The type of element where we are looking in. + */ + public function assert_element_not_contains_text($text, $element, $selectortype) { + + // Getting the container where the text should be found. + $container = $this->get_selected_node($selectortype, $element); + + // Looking for all the matching nodes without any other descendant matching the + // same xpath (we are using contains(., ....). + $xpathliteral = $this->getSession()->getSelectorsHandler()->xpathLiteral($text); + $xpath = "/descendant-or-self::*[contains(., $xpathliteral)]" . + "[count(descendant::*[contains(., $xpathliteral)]) = 0]"; + + // We should wait a while to ensure that the page is not still loading elements. + // Giving preference to the reliability of the results rather than to the performance. + try { + $nodes = $this->find_all('xpath', $xpath, false, $container); + } catch (ElementNotFoundException $e) { + // All ok. + return; + } + + // If we are not running javascript we have enough with the + // element not being found as we can't check if it is visible. + if (!$this->running_javascript()) { + throw new ExpectationException('"' . $text . '" text was found in the "' . $element . '" element', $this->getSession()); + } + + // We need to ensure all the found nodes are hidden. + $this->spin( + function($context, $args) { + + foreach ($args['nodes'] as $node) { + if ($node->isVisible()) { + throw new ExpectationException('"' . $args['text'] . '" text was found in the "' . $args['element'] . '" element', $context->getSession()); + } + } + + // If all the found nodes are hidden we are happy. + return true; + }, + array('nodes' => $nodes, 'text' => $text, 'element' => $element) + ); + } + + /** + * Checks, that the first specified element appears before the second one. + * + * @Given /^"(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)" should appear before "(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)"$/ + * @throws ExpectationException + * @param string $preelement The locator of the preceding element + * @param string $preselectortype The locator of the preceding element + * @param string $postelement The locator of the latest element + * @param string $postselectortype The selector type of the latest element + */ + public function should_appear_before($preelement, $preselectortype, $postelement, $postselectortype) { + + // We allow postselectortype as a non-text based selector. + list($preselector, $prelocator) = $this->transform_selector($preselectortype, $preelement); + list($postselector, $postlocator) = $this->transform_selector($postselectortype, $postelement); + + $prexpath = $this->find($preselector, $prelocator)->getXpath(); + $postxpath = $this->find($postselector, $postlocator)->getXpath(); + + // Using following xpath axe to find it. + $msg = '"'.$preelement.'" "'.$preselectortype.'" does not appear before "'.$postelement.'" "'.$postselectortype.'"'; + $xpath = $prexpath.'/following::*[contains(., '.$postxpath.')]'; + if (!$this->getSession()->getDriver()->find($xpath)) { + throw new ExpectationException($msg, $this->getSession()); + } + } + + /** + * Checks, that the first specified element appears after the second one. + * + * @Given /^"(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)" should appear after "(?P(?:[^"]|\\")*)" "(?P(?:[^"]|\\")*)"$/ + * @throws ExpectationException + * @param string $postelement The locator of the latest element + * @param string $postselectortype The selector type of the latest element + * @param string $preelement The locator of the preceding element + * @param string $preselectortype The locator of the preceding element + */ + public function should_appear_after($postelement, $postselectortype, $preelement, $preselectortype) { + + // We allow postselectortype as a non-text based selector. + list($postselector, $postlocator) = $this->transform_selector($postselectortype, $postelement); + list($preselector, $prelocator) = $this->transform_selector($preselectortype, $preelement); + + $postxpath = $this->find($postselector, $postlocator)->getXpath(); + $prexpath = $this->find($preselector, $prelocator)->getXpath(); + + // Using preceding xpath axe to find it. + $msg = '"'.$postelement.'" "'.$postselectortype.'" does not appear after "'.$preelement.'" "'.$preselectortype.'"'; + $xpath = $postxpath.'/preceding::*[contains(., '.$prexpath.')]'; + if (!$this->getSession()->getDriver()->find($xpath)) { + throw new ExpectationException($msg, $this->getSession()); + } + } + + /** + * Checks, that element of specified type is disabled. + * + * @Then /^the "(?P(?:[^"]|\\")*)" "(?P[^"]*)" should be disabled$/ + * @throws ExpectationException Thrown by BehatBase::find + * @param string $element Element we look in + * @param string $selectortype The type of element where we are looking in. + */ + public function the_element_should_be_disabled($element, $selectortype) { + + // Transforming from steps definitions selector/locator format to Mink format and getting the NodeElement. + $node = $this->get_selected_node($selectortype, $element); + + if (!$node->hasAttribute('disabled')) { + throw new ExpectationException('The element "' . $element . '" is not disabled', $this->getSession()); + } + } + + /** + * Checks, that element of specified type is enabled. + * + * @Then /^the "(?P(?:[^"]|\\")*)" "(?P[^"]*)" should be enabled$/ + * @throws ExpectationException Thrown by BehatBase::find + * @param string $element Element we look on + * @param string $selectortype The type of where we look + */ + public function the_element_should_be_enabled($element, $selectortype) { + + // Transforming from steps definitions selector/locator format to mink format and getting the NodeElement. + $node = $this->get_selected_node($selectortype, $element); + + if ($node->hasAttribute('disabled')) { + throw new ExpectationException('The element "' . $element . '" is not enabled', $this->getSession()); + } + } + + /** + * Checks the provided element and selector type are readonly on the current page. + * + * @Then /^the "(?P(?:[^"]|\\")*)" "(?P[^"]*)" should be readonly$/ + * @throws ExpectationException Thrown by BehatBase::find + * @param string $element Element we look in + * @param string $selectortype The type of element where we are looking in. + */ + public function the_element_should_be_readonly($element, $selectortype) { + // Transforming from steps definitions selector/locator format to Mink format and getting the NodeElement. + $node = $this->get_selected_node($selectortype, $element); + + if (!$node->hasAttribute('readonly')) { + throw new ExpectationException('The element "' . $element . '" is not readonly', $this->getSession()); + } + } + + /** + * Checks the provided element and selector type are not readonly on the current page. + * + * @Then /^the "(?P(?:[^"]|\\")*)" "(?P[^"]*)" should not be readonly$/ + * @throws ExpectationException Thrown by BehatBase::find + * @param string $element Element we look in + * @param string $selectortype The type of element where we are looking in. + */ + public function the_element_should_not_be_readonly($element, $selectortype) { + // Transforming from steps definitions selector/locator format to Mink format and getting the NodeElement. + $node = $this->get_selected_node($selectortype, $element); + + if ($node->hasAttribute('readonly')) { + throw new ExpectationException('The element "' . $element . '" is readonly', $this->getSession()); + } + } + + /** + * Checks the provided element and selector type exists in the current page. + * + * This step is for advanced users, use it if you don't find anything else suitable for what you need. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P[^"]*)" should exists$/ + * @throws ElementNotFoundException Thrown by BehatBase::find + * @param string $element The locator of the specified selector + * @param string $selectortype The selector type + */ + public function should_exists($element, $selectortype) { + + // Getting Mink selector and locator. + list($selector, $locator) = $this->transform_selector($selectortype, $element); + + // Will throw an ElementNotFoundException if it does not exist. + $this->find($selector, $locator); + } + + /** + * Checks that the provided element and selector type not exists in the current page. + * + * This step is for advanced users, use it if you don't find anything else suitable for what you need. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P[^"]*)" should not exists$/ + * @throws ExpectationException + * @param string $element The locator of the specified selector + * @param string $selectortype The selector type + */ + public function should_not_exists($element, $selectortype) { + + try { + $this->should_exists($element, $selectortype); + throw new ExpectationException('The "' . $element . '" "' . $selectortype . '" exists in the current page', $this->getSession()); + } catch (ElementNotFoundException $e) { + // It passes. + return; + } + } + + /** + * This step triggers cron like a user would do going to admin/cron.php. + * + * @Given /^I trigger cron$/ + */ + public function i_trigger_cron() { + $this->getSession()->visit($this->locate_path('/admin/cron.php')); + } + + /** + * Checks that an element and selector type exists in another element and selector type on the current page. + * + * This step is for advanced users, use it if you don't find anything else suitable for what you need. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P[^"]*)" should exist in the "(?P(?:[^"]|\\")*)" "(?P[^"]*)"$/ + * @throws ElementNotFoundException Thrown by BehatBase::find + * @param string $element The locator of the specified selector + * @param string $selectortype The selector type + * @param string $containerelement The container selector type + * @param string $containerselectortype The container locator + */ + public function should_exist_in_the($element, $selectortype, $containerelement, $containerselectortype) { + // Get the container node. + $containernode = $this->get_selected_node($containerselectortype, $containerelement); + + list($selector, $locator) = $this->transform_selector($selectortype, $element); + + // Specific exception giving info about where can't we find the element. + $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"'; + $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg); + + // Looks for the requested node inside the container node. + $this->find($selector, $locator, $exception, $containernode); + } + + /** + * Checks that an element and selector type does not exist in another element and selector type on the current page. + * + * This step is for advanced users, use it if you don't find anything else suitable for what you need. + * + * @Then /^"(?P(?:[^"]|\\")*)" "(?P[^"]*)" should not exist in the "(?P(?:[^"]|\\")*)" "(?P[^"]*)"$/ + * @throws ExpectationException + * @param string $element The locator of the specified selector + * @param string $selectortype The selector type + * @param string $containerelement The container selector type + * @param string $containerselectortype The container locator + */ + public function should_not_exist_in_the($element, $selectortype, $containerelement, $containerselectortype) { + try { + $this->should_exist_in_the($element, $selectortype, $containerelement, $containerselectortype); + throw new ExpectationException('The "' . $element . '" "' . $selectortype . '" exists in the "' . + $containerelement . '" "' . $containerselectortype . '"', $this->getSession()); + } catch (ElementNotFoundException $e) { + // It passes. + return; + } + } +} diff --git a/htdocs/testing/frameworks/behat/classes/BehatHooks.php b/htdocs/testing/frameworks/behat/classes/BehatHooks.php new file mode 100644 index 0000000000000000000000000000000000000000..eb31bc13218944bd736f605a49b67cb1aa944147 --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/BehatHooks.php @@ -0,0 +1,486 @@ +behat_dataroot and $CFG->behat_dbprefix instead of + // the normal site. + define('BEHAT_TEST', 1); + + define('CLI', 1); + define('INTERNAL', 1); + + // With BEHAT_TEST we will be using $CFG->behat_* instead of $CFG->dataroot, $CFG->dbprefix and $CFG->wwwroot. + require_once(dirname(dirname(dirname(dirname(__DIR__)))) . '/init.php'); + + // Now that we are in Mahara env. + require_once(get_config('docroot') . '/lib/upgrade.php'); + require_once(get_config('docroot') . '/lib/file.php'); + require_once(get_config('docroot') . '/testing/classes/TestLock.php'); + require_once(get_config('docroot') . '/testing/classes/NastyStrings.php'); + require_once(get_config('docroot') . '/testing/frameworks/behat/classes/util.php'); + require_once(get_config('docroot') . '/testing/frameworks/behat/classes/BehatCommand.php'); + require_once(get_config('docroot') . '/testing/frameworks/behat/classes/BehatSelectors.php'); + require_once(get_config('docroot') . '/testing/frameworks/behat/classes/BehatContextHelper.php'); + + // Avoids vendor/bin/behat to be executed directly without test environment enabled + // to prevent undesired db & dataroot modifications, this is also checked + // before each scenario (accidental user deletes) in the BeforeScenario hook. + + if (!BehatTestingUtil::is_test_mode_enabled()) { + throw new Exception('Behat only can run if test mode is enabled. More info in ' . BehatCommand::DOCS_URL . '#Running_tests'); + } + + if (!BehatTestingUtil::is_server_running()) { + throw new Exception($CFG->behat_wwwroot . + ' is not available, ensure you specified correct url and that the server is set up and started.' . + ' More info in ' . BehatCommand::DOCS_URL . '#Running_tests'); + } + + // Prevents using outdated data, upgrade script would start and tests would fail. + if (!BehatTestingUtil::is_test_data_updated()) { + $commandpath = 'php testing/frameworks/behat/cli/init.php'; + throw new Exception('Your behat test site is outdated, please run ' . $commandpath . ' from your mahara docroot to drop and install the behat test site again.'); + } + // Avoid parallel tests execution, it continues when the previous lock is released. + TestLock::acquire('behat'); + + if (!empty($CFG->behat_faildump_path) && !is_writable($CFG->behat_faildump_path)) { + throw new Exception('You set $CFG->behat_faildump_path to a non-writable directory'); + } + } + + /** + * Resets the test environment. + * + * @throws Exception If here we are not using the test database it should be because of a coding error + * @BeforeScenario + */ + public function before_scenario($event) { + global $CFG; + + // Check if the test environment is ready: dataroot, database, server + if (!defined('BEHAT_TEST') || + !defined('BEHAT_SITE_RUNNING') || + php_sapi_name() != 'cli' || + !BehatTestingUtil::is_test_mode_enabled() || + !BehatTestingUtil::is_test_site()) { + throw new Exception('Behat only can modify the test database and the test dataroot!'); + } + + // Check if the browser is running and supports javascript + $moreinfo = 'More info in ' . BehatCommand::DOCS_URL . '#Running_tests'; + $driverexceptionmsg = 'Selenium server is not running, you need to start it to run tests that involve Javascript. ' . $moreinfo; + try { + $session = $this->getSession(); + } + catch (CurlExec $e) { + // Exception thrown by WebDriver, so only @javascript tests will be caugth; in + throw new Exception($driverexceptionmsg); + } + catch (DriverException $e) { + throw new Exception($driverexceptionmsg); + } + catch (UnknownError $e) { + // Generic 'I have no idea' Selenium error. Custom exception to provide more feedback about possible solutions. + throw new Exception($e); + } + + // Register the named selectors for mahara + if (self::is_first_scenario()) { + BehatSelectors::register_mahara_selectors($session); + BehatContextHelper::set_session($session); + // Reset the browser + $session->restart(); + // Run all test with medium (1024x768) screen size, to avoid responsive problems. + $this->resize_window('medium'); + } + + // Reset $SESSION. + $_SESSION = array(); + $SESSION = new stdClass(); + $_SESSION['SESSION'] =& $SESSION; + + BehatTestingUtil::reset_database(); + BehatTestingUtil::reset_dataroot(); + + // Reset the nasty strings list used during the last test. + NastyStrings::reset_used_strings(); + + // Start always in the the homepage. + try { + // Let's be conservative as we never know when new upstream issues will affect us. + $session->visit($this->locate_path('/')); + } + catch (UnknownError $e) { + $this->Exception($e); + } + + // Checking that the root path is a mahara test site. + if (!self::$initprocessesfinished) { + $notestsiteexception = new Exception('The base URL (' . $CFG->wwwroot . ') is not a behat test site, ' . + 'ensure you started the built-in web server in the correct directory or your web server is correctly started and set up'); + $this->find("xpath", "//head/child::title[contains(., '" . BehatTestingUtil::BEHATSITENAME . "')]", $notestsiteexception); + + self::$initprocessesfinished = true; + } + } + + /** + * Wait for JS to complete before beginning interacting with the DOM. + * + * Executed only when running against a real browser. We wrap it + * all in a try & catch to forward the exception to i_look_for_exceptions + * so the exception will be at scenario level, which causes a failure, by + * default would be at framework level, which will stop the execution of + * the run. + * + * @BeforeStep @javascript + */ + public function before_step_javascript($event) { + + try { + $this->wait_for_pending_js(); + self::$currentstepexception = null; + } + catch (Exception $e) { + self::$currentstepexception = $e; + } + } + + /** + * Wait for JS to complete after finishing the step. + * + * With this we ensure that there are not AJAX calls + * still in progress. + * + * Executed only when running against a real browser. We wrap it + * all in a try & catch to forward the exception to i_look_for_exceptions + * so the exception will be at scenario level, which causes a failure, by + * default would be at framework level, which will stop the execution of + * the run. + * + * @AfterStep @javascript + */ + public function after_step_javascript($event) { + + try { + $this->wait_for_pending_js(); + self::$currentstepexception = null; + } + catch (UnexpectedAlertOpen $e) { + self::$currentstepexception = $e; + + // Accepting the alert so the framework can continue properly running + // the following scenarios. Some browsers already closes the alert, so + // wrapping in a try & catch. + try { + $this->getSession()->getDriver()->getWebDriverSession()->accept_alert(); + } + catch (Exception $e) { + // Catching the generic one as we never know how drivers reacts here. + } + } + catch (Exception $e) { + self::$currentstepexception = $e; + } + } + + /** + * Execute any steps required after the step has finished. + * + * This includes creating an HTML dump of the content if there was a failure. + * + * @AfterStep + */ + public function after_step($event) { + global $CFG; + + // Save the page content if the step failed. + if (!empty($CFG->behat_faildump_path) && + $event->getResult() === StepEvent::FAILED) { + $this->take_contentdump($event); + } + } + + /** + * Getter for self::$faildumpdirname + * + * @return string + */ + protected function get_run_faildump_dir() { + return self::$faildumpdirname; + } + + /** + * Take screenshot when a step fails. + * + * @throws Exception + * @param StepEvent $event + */ + protected function take_screenshot(StepEvent $event) { + // Goutte can't save screenshots. + if (!$this->running_javascript()) { + return false; + } + + list ($dir, $filename) = $this->get_faildump_filename($event, 'png'); + $this->saveScreenshot($filename, $dir); + } + + /** + * Take a dump of the page content when a step fails. + * + * @throws Exception + * @param StepEvent $event + */ + protected function take_contentdump(StepEvent $event) { + list ($dir, $filename) = $this->get_faildump_filename($event, 'html'); + + $fh = fopen($dir . DIRECTORY_SEPARATOR . $filename, 'w'); + fwrite($fh, $this->getSession()->getPage()->getContent()); + fclose($fh); + } + + /** + * Determine the full pathname to store a failure-related dump. + * + * This is used for content such as the DOM, and screenshots. + * + * @param StepEvent $event + * @param String $filetype The file suffix to use. Limited to 4 chars. + */ + protected function get_faildump_filename(StepEvent $event, $filetype) { + global $CFG; + + // All the contentdumps should be in the same parent dir. + if (!$faildumpdir = self::get_run_faildump_dir()) { + $faildumpdir = self::$faildumpdirname = date('Ymd_His'); + + $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir; + + if (!is_dir($dir) && !mkdir($dir, $CFG->directorypermissions, true)) { + // It shouldn't, we already checked that the directory is writable. + throw new Exception('No directories can be created inside $CFG->behat_faildump_path, check the directory permissions.'); + } + } + else { + // We will always need to know the full path. + $dir = $CFG->behat_faildump_path . DIRECTORY_SEPARATOR . $faildumpdir; + } + + // The scenario title + the failed step text. + // We want a i-am-the-scenario-title_i-am-the-failed-step.$filetype format. + $filename = $event->getStep()->getParent()->getTitle() . '_' . $event->getStep()->getText(); + $filename = preg_replace('/([^a-zA-Z0-9\_]+)/', '-', $filename); + + // File name limited to 255 characters. Leaving 4 chars for the file + // extension as we allow .png for images and .html for DOM contents. + $filename = substr($filename, 0, 250) . '.' . $filetype; + + return array($dir, $filename); + } + + /** + * Internal step definition to find exceptions, debugging() messages and PHP debug messages. + * + * Part of behat_hooks class as is part of the testing framework, is auto-executed + * after each step so no features will splicitly use it. + * + * @Given /^I look for exceptions$/ + * @throw Exception Unknown type, depending on what we caught in the hook or basic \Exception. + */ + public function i_look_for_exceptions() { + + // If the step already failed in a hook throw the exception. + if (!is_null(self::$currentstepexception)) { + throw self::$currentstepexception; + } + + // Wrap in try in case we were interacting with a closed window. + try { + + // Exceptions. + $exceptionsxpath = "//div[@data-rel='fatalerror']"; + // Debugging messages. + $debuggingxpath = "//div[@data-rel='debugging']"; + // PHP debug messages. + $phperrorxpath = "//div[@data-rel='phpdebugmessage']"; + // Any other backtrace. + $othersxpath = "(//*[contains(., ': call to ')])[1]"; + + $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath); + $joinedxpath = implode(' | ', $xpaths); + + // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check + // is faster than to send the 4 xpath queries for each step. + if (!$this->getSession()->getDriver()->find($joinedxpath)) { + return; + } + + // Exceptions. + if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) { + + // Getting the debugging info and the backtrace. + $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error'); + // If errorinfoboxes is empty, try find notifytiny (original) class. + if (empty($errorinfoboxes)) { + $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny'); + } + $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" . + $this->get_debug_text($errorinfoboxes[1]->getHtml()); + + $msg = "mahara exception: " . $errormsg->getText() . "\n" . $errorinfo; + throw new \Exception(html_entity_decode($msg)); + } + + // Debugging messages. + if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) { + $msgs = array(); + foreach ($debuggingmessages as $debuggingmessage) { + $msgs[] = $this->get_debug_text($debuggingmessage->getHtml()); + } + $msg = "debugging() message/s found:\n" . implode("\n", $msgs); + throw new \Exception(html_entity_decode($msg)); + } + + // PHP debug messages. + if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) { + + $msgs = array(); + foreach ($phpmessages as $phpmessage) { + $msgs[] = $this->get_debug_text($phpmessage->getHtml()); + } + $msg = "PHP debug message/s found:\n" . implode("\n", $msgs); + throw new \Exception(html_entity_decode($msg)); + } + + // Any other backtrace. + // First looking through xpath as it is faster than get and parse the whole page contents, + // we get the contents and look for matches once we found something to suspect that there is a backtrace. + if ($this->getSession()->getDriver()->find($othersxpath)) { + $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/'; + if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) { + $msgs = array(); + foreach ($backtraces[0] as $backtrace) { + $msgs[] = $backtrace . '()'; + } + $msg = "Other backtraces found:\n" . implode("\n", $msgs); + throw new \Exception(htmlentities($msg)); + } + } + + } + catch (NoSuchWindow $e) { + // If we were interacting with a popup window it will not exists after closing it. + } + } + + /** + * Converts HTML tags to line breaks to display the info in CLI + * + * @param string $html + * @return string + */ + protected function get_debug_text($html) { + + // Replacing HTML tags for new lines and keeping only the text. + $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html); + return preg_replace("/(\n)+/s", "\n", $notags); + } + + /** + * Returns whether the first scenario of the suite is running + * + * @return bool + */ + protected static function is_first_scenario() { + return !(self::$initprocessesfinished); + } + + /** + * Throws an exception after appending an extra info text. + * + * @throws Exception + * @param UnknownError $exception + * @return void + */ + protected function throw_unknown_exception(UnknownError $exception) { + $text = get_string('unknownexceptioninfo', 'tool_behat'); + throw new Exception($text . PHP_EOL . $exception->getMessage()); + } + +} + diff --git a/htdocs/testing/frameworks/behat/classes/BehatSelectors.php b/htdocs/testing/frameworks/behat/classes/BehatSelectors.php new file mode 100644 index 0000000000000000000000000000000000000000..09a852cc4ac5996806e92935804b0043edaf4499 --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/BehatSelectors.php @@ -0,0 +1,155 @@ + 'dialogue', + 'block' => 'block', + 'region' => 'region', + 'table_row' => 'table_row', + 'table' => 'table', + 'fieldset' => 'fieldset', + 'css_element' => 'css_element', + 'xpath_element' => 'xpath_element' + ); + + /** + * @var Allowed types when using selector arguments. + */ + protected static $allowedselectors = array( + 'dialogue' => 'dialogue', + 'block' => 'block', + 'region' => 'region', + 'table_row' => 'table_row', + 'link' => 'link', + 'button' => 'button', + 'link_or_button' => 'link_or_button', + 'select' => 'select', + 'checkbox' => 'checkbox', + 'radio' => 'radio', + 'file' => 'file', + 'filemanager' => 'filemanager', + 'optgroup' => 'optgroup', + 'option' => 'option', + 'table' => 'table', + 'field' => 'field', + 'fieldset' => 'fieldset', + 'text' => 'text', + 'css_element' => 'css_element', + 'xpath_element' => 'xpath_element' + ); + + /** + * Behat by default comes with XPath, CSS and named selectors, + * named selectors are a mapping between names (like button) and + * xpaths that represents that names and includes a placeholder that + * will be replaced by the locator. These are mahara's own xpaths. + * + * @var XPaths for mahara elements. + */ + protected static $maharaselectors = array( + 'text' => << << << << << <<getSelectorsHandler()->xpathLiteral($element)); + $selector = 'named'; + } + + return array($selector, $locator); + } + + /** + * Adds mahara selectors as behat named selectors. + * + * @param Session $session The mink session + * @return void + */ + public static function register_mahara_selectors(Behat\Mink\Session $session) { + + foreach (self::get_mahara_selectors() as $name => $xpath) { + $session->getSelectorsHandler()->getSelector('named')->registerNamedXpath($name, $xpath); + } + } + + /** + * Allowed selectors getter. + * + * @return array + */ + public static function get_allowed_selectors() { + return self::$allowedselectors; + } + + /** + * Allowed text selectors getter. + * + * @return array + */ + public static function get_allowed_text_selectors() { + return self::$allowedtextselectors; + } + + /** + * Mahara selectors attribute accessor. + * + * @return array + */ + protected static function get_mahara_selectors() { + return self::$maharaselectors; + } +} diff --git a/htdocs/testing/frameworks/behat/classes/util.php b/htdocs/testing/frameworks/behat/classes/util.php new file mode 100644 index 0000000000000000000000000000000000000000..59d11b05cf594145aa761b13cb7aeb58efbc0812 --- /dev/null +++ b/htdocs/testing/frameworks/behat/classes/util.php @@ -0,0 +1,298 @@ + array( + 'password' => 'Password1', + 'email' => 'admin@test.mahara.org', + ), + 'sitename' => self::BEHATSITENAME, + ); + + /** + * @var array Files to skip when resetting dataroot folder + */ + protected static $datarootskiponreset = array('.', '..', 'behat', 'behattestdir.txt'); + + /** + * @var array Files to skip when dropping dataroot folder + */ + protected static $datarootskipondrop = array('.', '..', 'lock'); + + /** + * Installs a site using $CFG->dataroot and $CFG->dbprefix + * As we are setting up the behat test environment, these settings + * are replaced by $CFG->behat_dataroot and $CFG->behat_dbprefix + * + * @throws SystemException + * @return void + */ + public static function install_site() { + if (!defined('BEHAT_UTIL')) { + throw new SystemException('This method can be only used by Behat CLI tool'); + } + + $tables = get_tables(); + if (!empty($tables)) { + behat_error(BEHAT_EXITCODE_INSTALLED); + } + + // New dataroot. + self::reset_dataroot(); + + // Determine what we will install + $upgrades = check_upgrades(); + $upgrades['firstcoredata'] = true; + $upgrades['localpreinst'] = true; + $upgrades['lastcoredata'] = true; + $upgrades['localpostinst'] = true; + upgrade_mahara($upgrades); + + $userobj = new User(); + $userobj = $userobj->find_by_username('admin'); + $userobj->email = self::$sitedefaultinfo['admin']['email']; + $userobj->commit(); + + // Password changes should be performed by the authfactory + $authobj = AuthFactory::create($userobj->authinstance); + $authobj->change_password($userobj, self::$sitedefaultinfo['admin']['password'], true); + + // Set site name + set_config('sitename', self::$sitedefaultinfo['sitename']); + + // We need to keep the installed dataroot artefact files. + // So each time we reset the dataroot before running a test, the default files are still installed. + self::save_original_data_files(); + + // Disable some settings that are not wanted on test sites. + set_config('sendemail', false); + + // Keeps the current version of database and dataroot. + self::store_versions_hash(); + + // Stores the database contents for fast reset. + self::store_database_state(); + } + + /** + * Drops dataroot and remove test database tables + * @throws MaharaBehatTestException + * @return void + */ + public static function drop_site() { + + if (!defined('BEHAT_UTIL')) { + throw new MaharaBehatTestException('This method can be only used by Behat CLI tool'); + } + + self::reset_dataroot(); + self::drop_dataroot(); + self::drop_database(true); + } + + /** + * Checks if $CFG->behat_wwwroot is available + * + * @return bool + */ + public static function is_server_running() { + global $CFG; + + $request = mahara_http_request(array( + CURLOPT_URL => $CFG->behat_wwwroot, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => '', + ), true); + + return !$request->error; + } + + /** + * Checks whether the test database and dataroot is ready + * Stops execution if something went wrong + * @throws MaharaBehatTestException + * @return void + */ + protected static function test_environment_problem() { + + if (!defined('BEHAT_UTIL')) { + throw new MaharaBehatTestException('This method can be only used by Behat CLI tool'); + } + + if (!self::is_test_site()) { + behat_error(1, 'This is not a behat test site!'); + } + + $tables = get_tables(); + if (empty($tables)) { + behat_error(BEHAT_EXITCODE_INSTALL, ''); + } + + if (!self::is_test_data_updated()) { + behat_error(BEHAT_EXITCODE_REINSTALL, 'The test environment was initialised for a different version'); + } + } + + /** + * Enables test mode + * + * It uses CFG->behat_dataroot + * + * Starts the test mode checking the composer installation and + * the test environment and updating the available + * features and steps definitions. + * + * Stores a file in dataroot/behat to allow Mahara to switch + * to the test environment when using cli-server. + * @throws MaharaBehatTestException + * @return void + */ + public static function start_test_mode() { + global $CFG; + + if (!defined('BEHAT_UTIL')) { + throw new MaharaBehatTestException('This method can be only used by Behat CLI tool'); + } + + // Checks the behat set up and the PHP version. + if ($errorcode = BehatCommand::behat_setup_problem()) { + exit($errorcode); + } + + // Check that test environment is correctly set up. + self::test_environment_problem(); + + // Updates all the Mahara features and steps definitions. + BehatConfigManager::update_config_file(); + + if (self::is_test_mode_enabled()) { + return; + } + + $contents = '$CFG->behat_wwwroot, $CFG->behat_dbprefix and $CFG->behat_dataroot' . + ' are currently used as $CFG->wwwroot, $CFG->dbprefix and $CFG->dataroot'; + $filepath = self::get_test_file_path(); + if (!file_put_contents($filepath, $contents)) { + behat_error(BEHAT_EXITCODE_PERMISSIONS, 'File ' . $filepath . ' can not be created'); + } + } + + /** + * Returns the status of the behat test environment + * + * @return int Error code + */ + public static function get_behat_status() { + + if (!defined('BEHAT_UTIL')) { + throw new MaharaBehatTestException('This method can be only used by Behat CLI tool'); + } + + // Checks the behat set up and the PHP version, returning an error code if something went wrong. + if ($errorcode = BehatCommand::behat_setup_problem()) { + return $errorcode; + } + + // Check that test environment is correctly set up, stops execution. + self::test_environment_problem(); + } + + /** + * Disables test mode + * @throws MaharaBehatTestException + * @return void + */ + public static function stop_test_mode() { + + if (!defined('BEHAT_UTIL')) { + throw new MaharaBehatTestException('This method can be only used by Behat CLI tool'); + } + + $testenvfile = self::get_test_file_path(); + + if (!self::is_test_mode_enabled()) { + echo "Test environment was already disabled\n"; + } else { + if (!unlink($testenvfile)) { + behat_error(BEHAT_EXITCODE_PERMISSIONS, 'Can not delete test environment file'); + } + } + } + + /** + * Checks whether test environment is enabled or disabled + * + * To check is the current script is running in the test + * environment + * + * @return bool + */ + public static function is_test_mode_enabled() { + + $testenvfile = self::get_test_file_path(); + if (file_exists($testenvfile)) { + return true; + } + + return false; + } + + /** + * Returns the path to the file which specifies if test environment is enabled + * @return string + */ + protected final static function get_test_file_path() { + return BehatCommand::get_behat_dir() . '/test_environment_enabled.txt'; + } + +} + + +/** + * Test exceptions. Usually the fault of runing behat tests + * So they extend SystemException. + */ +class MaharaBehatTestException extends SystemException { } + +/** + * Bootstrap exceptions. Usually the fault of the behat.yml + * So they extend ConfigException. + */ +class MaharaBehatTestBootstrapException extends ConfigException { } \ No newline at end of file diff --git a/htdocs/testing/frameworks/behat/cli/init.php b/htdocs/testing/frameworks/behat/cli/init.php new file mode 100644 index 0000000000000000000000000000000000000000..5e05dd823b5ec343a0c071717b80284116a480b8 --- /dev/null +++ b/htdocs/testing/frameworks/behat/cli/init.php @@ -0,0 +1,80 @@ +behat_dataroot for $CFG->dataroot + * and $CFG->behat_dbprefix for $CFG->dbprefix + */ + +define('INTERNAL', 1); +define('ADMIN', 1); +define('CLI', 1); +define('BEHAT_UTIL', 1); + +// No access from web! +isset($_SERVER['REMOTE_ADDR']) && die(); + +// Mahara libs +// Loading mahara init. +require(dirname(dirname(dirname(dirname(__DIR__)))) . '/init.php'); +require_once(get_config('docroot') . '/lib/upgrade.php'); +require_once(get_config('docroot') . '/local/install.php'); +require_once(get_config('docroot') . '/lib/cli.php'); +require_once(get_config('docroot') . '/lib/file.php'); +// Behat utilities. +require_once(get_config('docroot') . '/testing/classes/TestLock.php'); +require_once(get_config('docroot') . '/testing/frameworks/behat/lib.php'); +require_once(get_config('docroot') . '/testing/frameworks/behat/classes/util.php'); +require_once(get_config('docroot') . '/testing/frameworks/behat/classes/BehatCommand.php'); + + +$cli = get_cli(); + +$options = array(); + +$options['install'] = new stdClass(); +$options['install']->shortoptions = array('i'); +$options['install']->description = 'Installs the test environment for acceptance tests'; +$options['install']->required = false; +$options['install']->defaultvalue = false; + +$options['drop'] = new stdClass(); +$options['drop']->shortoptions = array('u'); +$options['drop']->description = 'Drops the database tables and the dataroot contents'; +$options['drop']->required = false; +$options['drop']->defaultvalue = false; + +$options['enable'] = new stdClass(); +$options['enable']->shortoptions = array('e'); +$options['enable']->description = 'Enables test environment and updates tests list'; +$options['enable']->required = false; +$options['enable']->defaultvalue = false; + +$options['disable'] = new stdClass(); +$options['disable']->shortoptions = array('d'); +$options['disable']->description = 'Disables test environment'; +$options['disable']->required = false; +$options['disable']->defaultvalue = false; + +$options['diag'] = new stdClass(); +$options['diag']->description = 'Get behat test environment status code'; +$options['diag']->required = false; +$options['diag']->defaultvalue = false; + +$settings = new stdClass(); +$settings->options = $options; +$settings->info = 'CLI tool to manage Behat integration in Mahara'; + +$cli->setup($settings); + +try { + if ($cli->get_cli_param('install')) { + BehatTestingUtil::install_site(); + cli::cli_exit("\nAcceptance test site is installed\n"); + } + else if ($cli->get_cli_param('drop')) { + TestLock::acquire('behat'); + BehatTestingUtil::drop_site(); + cli::cli_exit("\nAcceptance tests site dropped\n"); + } + else if ($cli->get_cli_param('enable')) { + BehatTestingUtil::start_test_mode(); + $runtestscommand = BehatCommand::get_behat_command(true) . + ' --config ' . BehatConfigManager::get_behat_cli_config_filepath(); + cli::cli_exit("\nAcceptance tests environment enabled on $CFG->behat_wwwroot, to run the tests use:\n " . $runtestscommand . "\n"); + } + else if ($cli->get_cli_param('disable')) { + BehatTestingUtil::stop_test_mode(); + cli::cli_exit("\nAcceptance test site is disabled\n"); + } + else if ($cli->get_cli_param('diag')) { + $code = BehatTestingUtil::get_behat_status(); + exit($code); + } +} +catch (Exception $e) { + cli::cli_exit($e->getMessage(), true); +} + +exit(0); diff --git a/htdocs/testing/frameworks/behat/features/bootstrap/BehatMaharaInitContext.php b/htdocs/testing/frameworks/behat/features/bootstrap/BehatMaharaInitContext.php new file mode 100644 index 0000000000000000000000000000000000000000..a44671bdc44c3dee7ad506970783bec1af9061ea --- /dev/null +++ b/htdocs/testing/frameworks/behat/features/bootstrap/BehatMaharaInitContext.php @@ -0,0 +1,35 @@ +useContext('BehatHooks', new BehatHooks($parameters)); + $this->useContext('mahara', new MaharaContext($parameters)); + } + +} diff --git a/htdocs/testing/frameworks/behat/features/extensions/MaharaExtension.php b/htdocs/testing/frameworks/behat/features/extensions/MaharaExtension.php new file mode 100644 index 0000000000000000000000000000000000000000..21071adffbe86ef95deba40d13bf68ff6a14ad94 --- /dev/null +++ b/htdocs/testing/frameworks/behat/features/extensions/MaharaExtension.php @@ -0,0 +1,257 @@ +load('core.xml'); + + // Getting the extension parameters. + $container->setParameter('behat.mahara.parameters', $config); + + // Adding mahara formatters to the list of supported formatted. + if (isset($config['formatters'])) { + $container->setParameter('behat.formatter.classes', $config['formatters']); + } + } + + /** + * Setups configuration for current extension. + * + * @param ArrayNodeDefinition $builder + */ + public function getConfig(ArrayNodeDefinition $builder) { + $builder-> + children()-> + arrayNode('features')-> + useAttributeAsKey('key')-> + prototype('variable')->end()-> + end()-> + arrayNode('steps_definitions')-> + useAttributeAsKey('key')-> + prototype('variable')->end()-> + end()-> + arrayNode('formatters')-> + useAttributeAsKey('key')-> + prototype('variable')->end()-> + end()-> + + end()-> + end(); + } + + /** + * Returns compiler passes used by this extension. + * + * @return array + */ + public function getCompilerPasses() { + return array(); + } + +} + +/** + * MaharaContext initializer + */ +class MaharaAwareInitializer implements InitializerInterface { + private $parameters; + + public function __construct(array $parameters) { + $this->parameters = $parameters; + } + + /** + * @see Behat\Behat\Context\Initializer.InitializerInterface::supports() + * @param ContextInterface $context + */ + public function supports(ContextInterface $context) { + return ($context instanceof MaharaContext); + } + + /** + * Passes the Mahara config to the main Mahara context + * @see Behat\Behat\Context\Initializer.InitializerInterface::initialize() + * @param ContextInterface $context + */ + public function initialize(ContextInterface $context) + { + $context->setMaharaConfig($this->parameters); + } +} + +/** + * Mahara contexts loader + * + * It gathers all the available steps definitions reading the + * Mahara configuration file + * + */ +class MaharaContext extends BehatContext { + + /** + * Mahara features and steps definitions list + * @var array + */ + protected $maharaConfig; + + /** + * Includes all the specified Mahara subcontexts + * @param array $parameters + */ + public function setMaharaConfig($parameters) { + $this->maharaConfig = $parameters; + + if (!is_array($this->maharaConfig)) { + throw new RuntimeException('There are no Mahara features nor steps definitions'); + } + + // Using the key as context identifier. + if (!empty($this->maharaConfig['steps_definitions'])) { + foreach ($this->maharaConfig['steps_definitions'] as $classname => $path) { + if (file_exists($path)) { + require_once($path); + $this->useContext($classname, new $classname()); + } + } + } + } +} + +/** + * Gherkin extension to load multiple features folders + * + * Like Mahara, Mahara has multiple features folders across all Mahara + * plugins (including 3rd party plugins) this extension loads + * the available features + * + */ +class MaharaGherkin extends Gherkin { + + /** + * Mahara config + * @var array + */ + protected $maharaConfig; + + /** + * Loads the Mahara config + * + * @param array $parameters + */ + public function __construct($parameters) { + $this->maharaConfig = $parameters; + } + + /** + * Multiple features folders loader + * + * Delegates load execution to parent including filters management + * + * @param mixed $resource Resource to load + * @param array $filters Additional filters + * @return array + */ + public function load($resource, array $filters = array()) { + + // If a resource is specified don't overwrite the parent behaviour. + if ($resource != '') { + return parent::load($resource, $filters); + } + + if (!is_array($this->maharaConfig)) { + throw new RuntimeException('There are no Mahara features nor steps definitions'); + } + + // Loads all the features files of each Mahara plugin. + $features = array(); + if (!empty($this->maharaConfig['features'])) { + foreach ($this->maharaConfig['features'] as $path) { + if (file_exists($path)) { + $features = array_merge($features, parent::load($path, $filters)); + } + } + } + return $features; + } + +} + +/** + * MaharaProgressFormatter + * + * Basic ProgressFormatter extension to add the site + * info to the CLI output. + * + */ +class MaharaProgressFormatter extends ProgressFormatter { + + /** + * Adding beforeSuite event. + * + * @return array The event names to listen to. + */ + public static function getSubscribedEvents() + { + $events = parent::getSubscribedEvents(); + $events['beforeSuite'] = 'beforeSuite'; + + return $events; + } + + /** + * We print the site info + driver used and OS. + * + * At this point behat_hooks::before_suite() already + * ran, so we have $CFG and family. + * + * @param SuiteEvent $event + * @return void + */ + public function beforeSuite(SuiteEvent $event) + { + global $CFG; + + require_once($CFG->docroot . '/testing/frameworks/behat/classes/util.php'); + + // Calling all directly from here as we avoid more behat framework extensions. + $runinfo = \BehatTestingUtil::get_site_info(); + $runinfo .= 'Server OS "' . PHP_OS . '"' . ', Browser: "firefox"' . PHP_EOL; + $runinfo .= 'Started at ' . date('d-m-Y, H:i', time()); + + $this->writeln($runinfo); + } + +} + + +return new MaharaExtension(); diff --git a/htdocs/testing/frameworks/behat/features/extensions/core.xml b/htdocs/testing/frameworks/behat/features/extensions/core.xml new file mode 100644 index 0000000000000000000000000000000000000000..cef79e2a4755d93dc2656895590fe427d153bfe9 --- /dev/null +++ b/htdocs/testing/frameworks/behat/features/extensions/core.xml @@ -0,0 +1,25 @@ + + + + + + MaharaGherkin + MaharaAwareInitializer + + + + + + %behat.mahara.parameters% + + + + + + %behat.mahara.parameters% + + + + diff --git a/htdocs/testing/frameworks/behat/lib.php b/htdocs/testing/frameworks/behat/lib.php new file mode 100644 index 0000000000000000000000000000000000000000..e13b09fd20ac0088242eb4c2cec985fef4385b25 --- /dev/null +++ b/htdocs/testing/frameworks/behat/lib.php @@ -0,0 +1,282 @@ +' . PHP_EOL; + echo "$errnostr: $errstr in $errfile on line $errline" . PHP_EOL; + echo ''; + + // Also use the internal error handler so we keep the usual behaviour. + return false; +} + +/** + * Restrict the config.php settings allowed. + * + * When running the behat features the config.php + * settings should not affect the results. + * + * @return void + */ +function behat_clean_init_config() { + global $CFG; + + $allowed = array_flip(array( + 'wwwroot', 'docroot', 'dataroot', 'admin', 'directorypermissions', 'filepermissions', + 'dbtype', 'dbhost', 'dbname', 'dbuser', 'dbpass', 'dbprefix', 'error_reporting', + 'sessionpath' + )); + + // Add extra allowed settings. + if (!empty($CFG->behat_extraallowedsettings)) { + $allowed = array_merge($allowed, array_flip($CFG->behat_extraallowedsettings)); + } + + // Also allowing behat_ prefixed attributes. + foreach ($CFG as $key => $value) { + if (!isset($allowed[$key]) && strpos($key, 'behat_') !== 0) { + unset($CFG->{$key}); + } + } + +} + +/** + * Checks that the behat config vars are properly set. + * + * @return void Stops execution with error code if something goes wrong. + */ +function behat_check_config_vars() { + global $CFG; + + // Verify prefix value. + if (empty($CFG->behat_dbprefix)) { + behat_error(BEHAT_EXITCODE_CONFIG, + 'Define $CFG->behat_dbprefix in config.php'); + } + if (!empty($CFG->dbprefix) and $CFG->behat_dbprefix == $CFG->dbprefix) { + behat_error(BEHAT_EXITCODE_CONFIG, + '$CFG->behat_dbprefix in config.php must be different from $CFG->dbprefix'); + } + if (!empty($CFG->phpunit_dbprefix) and $CFG->behat_dbprefix == $CFG->phpunit_dbprefix) { + behat_error(BEHAT_EXITCODE_CONFIG, + '$CFG->behat_dbprefix in config.php must be different from $CFG->phpunit_dbprefix'); + } + + // Verify behat wwwroot value. + if (empty($CFG->behat_wwwroot)) { + behat_error(BEHAT_EXITCODE_CONFIG, + 'Define $CFG->behat_wwwroot in config.php'); + } + if (!empty($CFG->wwwroot) and $CFG->behat_wwwroot == $CFG->wwwroot) { + behat_error(BEHAT_EXITCODE_CONFIG, + '$CFG->behat_wwwroot in config.php must be different from $CFG->wwwroot'); + } + + // Verify behat dataroot value. + if (empty($CFG->behat_dataroot)) { + behat_error(BEHAT_EXITCODE_CONFIG, + 'Define $CFG->behat_dataroot in config.php'); + } + if (!file_exists($CFG->behat_dataroot)) { + $permissions = isset($CFG->directorypermissions) ? $CFG->directorypermissions : 02777; + umask(0); + if (!mkdir($CFG->behat_dataroot, $permissions, true)) { + behat_error(BEHAT_EXITCODE_PERMISSIONS, '$CFG->behat_dataroot directory can not be created'); + } + } + $CFG->behat_dataroot = realpath($CFG->behat_dataroot); + if (empty($CFG->behat_dataroot) or !is_dir($CFG->behat_dataroot) or !is_writable($CFG->behat_dataroot)) { + behat_error(BEHAT_EXITCODE_CONFIG, + '$CFG->behat_dataroot in config.php must point to an existing writable directory'); + } + if (!empty($CFG->dataroot) and $CFG->behat_dataroot == realpath($CFG->dataroot)) { + behat_error(BEHAT_EXITCODE_CONFIG, + '$CFG->behat_dataroot in config.php must be different from $CFG->dataroot'); + } + if (!empty($CFG->phpunit_dataroot) and $CFG->behat_dataroot == realpath($CFG->phpunit_dataroot)) { + behat_error(BEHAT_EXITCODE_CONFIG, + '$CFG->behat_dataroot in config.php must be different from $CFG->phpunit_dataroot'); + } +} + +/** + * Should we switch to the test site data? + * @return bool + */ +function behat_is_test_site() { + global $CFG; + + if (defined('BEHAT_UTIL')) { + // This is the framework tool that installs/drops the test site install. + return true; + } + if (defined('BEHAT_TEST')) { + // This is the main vendor/bin/behat script. + return true; + } + if (empty($CFG->behat_wwwroot)) { + return false; + } + if (isset($_SERVER['REMOTE_ADDR']) and behat_is_requested_url($CFG->behat_wwwroot)) { + // Something is accessing the web server like a real browser. + return true; + } + + return false; +} + +/** + * Checks if the URL requested by the user matches the provided argument + * + * @param string $url + * @return bool Returns true if it matches. + */ +function behat_is_requested_url($url) { + + $parsedurl = parse_url($url . '/'); + $parsedurl['port'] = isset($parsedurl['port']) ? $parsedurl['port'] : 80; + $parsedurl['path'] = rtrim($parsedurl['path'], '/'); + + // Removing the port. + $pos = strpos($_SERVER['HTTP_HOST'], ':'); + if ($pos !== false) { + $requestedhost = substr($_SERVER['HTTP_HOST'], 0, $pos); + } else { + $requestedhost = $_SERVER['HTTP_HOST']; + } + + // The path should also match. + if (empty($parsedurl['path'])) { + $matchespath = true; + } else if (strpos($_SERVER['SCRIPT_NAME'], $parsedurl['path']) === 0) { + $matchespath = true; + } + + // The host and the port should match + if ($parsedurl['host'] == $requestedhost && $parsedurl['port'] == $_SERVER['SERVER_PORT'] && !empty($matchespath)) { + return true; + } + + return false; +} + diff --git a/htdocs/testing/lib.php b/htdocs/testing/lib.php new file mode 100644 index 0000000000000000000000000000000000000000..6a7a34bd9c028d537978fcaac4f509054409e80f --- /dev/null +++ b/htdocs/testing/lib.php @@ -0,0 +1,140 @@ +docroot . $maharapath); + + if (strpos($path, $cwd) === 0) { + $path = substr($path, strlen($cwd)); + } + + return $path; +} + +/** + * Try to change permissions to $CFG->docroot or $CFG->dataroot if possible + * @param string $file + * @return bool success + */ +function testing_fix_file_permissions($file) { + global $CFG; + + $permissions = fileperms($file); + if ($permissions & $CFG->filepermissions != $CFG->filepermissions) { + $permissions = $permissions | $CFG->filepermissions; + return chmod($file, $permissions); + } + + return true; +} + +/** + * Mark empty dataroot to be used for testing. + * @param string $dataroot The dataroot directory + * @param string $framework The test framework + * @return void + */ +function testing_initdataroot($dataroot, $framework) { + global $CFG; + + $filename = $dataroot . '/' . $framework . 'testdir.txt'; + + umask(0); + if (!file_exists($filename)) { + file_put_contents($filename, 'Contents of this directory are used during tests only, do not delete this file!'); + } + testing_fix_file_permissions($filename); + + $varname = $framework . '_dataroot'; + $datarootdir = $CFG->{$varname} . '/' . $framework; + if (!file_exists($datarootdir)) { + mkdir($datarootdir, $CFG->directorypermissions); + } +} + +/** + * Prints an error and stops execution + * + * @param integer $errorcode + * @param string $text + * @return void exits + */ +function testing_error($errorcode, $text = '') { + + // do not write to error stream because we need the error message in PHP exec result from web ui + echo($text."\n"); + exit($errorcode); +} + +/** + * Updates the composer installer and the dependencies. + * + * Includes --dev dependencies. + * + * @return void exit() if something goes wrong + */ +function testing_update_composer_dependencies() { + + // To restore the value after finishing. + $cwd = getcwd(); + + // Mahara Docroot. + $maharadocroot = dirname(__DIR__); + chdir($maharadocroot); + + // Download composer.phar if we can. + if (!file_exists($maharadocroot . '/composer.phar')) { + passthru("curl http://getcomposer.org/installer | php", $code); + if ($code != 0) { + exit($code); + } + } + else { + + // If it is already there update the installer. + passthru("php composer.phar self-update", $code); + if ($code != 0) { + exit($code); + } + } + + // Update composer dependencies. + passthru("php composer.phar update --dev", $code); + if ($code != 0) { + exit($code); + } + + chdir($cwd); +} diff --git a/htdocs/user/tests/behat/Login.feature b/htdocs/user/tests/behat/Login.feature new file mode 100644 index 0000000000000000000000000000000000000000..0dbcf85d8f5301e2583d381339410248bb3d0c5f --- /dev/null +++ b/htdocs/user/tests/behat/Login.feature @@ -0,0 +1,8 @@ +Feature: Login + @javascript + Scenario: Login + Given I am on homepage + When I fill in "login_username" with "admin" + And I fill in "login_password" with "Password1" + And I press "Login" + Then I should see "Dashboard" \ No newline at end of file