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