diff options
-rw-r--r-- | modules/simpletest/default_reporter.php | 75 | ||||
-rw-r--r-- | modules/simpletest/drupal_reporter.php | 257 | ||||
-rw-r--r-- | modules/simpletest/drupal_test_suite.php | 121 | ||||
-rw-r--r-- | modules/simpletest/drupal_unit_test_case.php | 97 | ||||
-rw-r--r-- | modules/simpletest/drupal_web_test_case.php | 1051 | ||||
-rw-r--r-- | modules/simpletest/dumper.php | 80 | ||||
-rw-r--r-- | modules/simpletest/errors.php | 240 | ||||
-rw-r--r-- | modules/simpletest/exceptions.php | 171 | ||||
-rw-r--r-- | modules/simpletest/expectation.php | 144 | ||||
-rw-r--r-- | modules/simpletest/invoker.php | 119 | ||||
-rw-r--r-- | modules/simpletest/reporter.php | 283 | ||||
-rw-r--r-- | modules/simpletest/scorer.php | 413 | ||||
-rw-r--r-- | modules/simpletest/test_case.php | 615 | ||||
-rw-r--r-- | modules/simpletest/unit_tester.php | 178 | ||||
-rw-r--r-- | modules/simpletest/xml.php | 641 |
15 files changed, 4485 insertions, 0 deletions
diff --git a/modules/simpletest/default_reporter.php b/modules/simpletest/default_reporter.php new file mode 100644 index 000000000..8a760353d --- /dev/null +++ b/modules/simpletest/default_reporter.php @@ -0,0 +1,75 @@ +<?php +// $Id$ + +/** + * Parser for command line arguments. Extracts + * the a specific test to run and engages XML + * reporting when necessary. + */ +class SimpleCommandLineParser { + protected $to_property = array('c' => 'case', 't' => 'test'); + protected $case = ''; + protected $test = ''; + protected $xml = false; + + function SimpleCommandLineParser($arguments) { + if (!is_array($arguments)) { + return; + } + foreach ($arguments as $i => $argument) { + if (preg_match('/^--?(test|case|t|c)=(.+)$/', $argument, $matches)) { + $this->{$this->_parseProperty($matches[1])} = $matches[2]; + } + elseif (preg_match('/^--?(test|case|t|c)$/', $argument, $matches)) { + if (isset($arguments[$i + 1])) { + $this->{$this->_parseProperty($matches[1])} = $arguments[$i + 1]; + } + } + elseif (preg_match('/^--?(xml|x)$/', $argument)) { + $this->xml = true; + } + } + } + function _parseProperty($property) { + if (isset($this->to_property[$property])) { + return $this->to_property[$property]; + } + else { + return $property; + } + } + function getTest() { + return $this->test; + } + + function getTestCase() { + return $this->case; + } + + function isXml() { + return $this->xml; + } +} + +/** + * The default reporter used by SimpleTest's autorun + * feature. The actual reporters used are dependency + * injected and can be overridden. + */ +class DefaultReporter extends SimpleReporterDecorator { + /** + * Assembles the appopriate reporter for the environment. + */ + function DefaultReporter() { + if (SimpleReporter::inCli()) { + global $argv; + $parser = new SimpleCommandLineParser($argv); + $interfaces = $parser->isXml() ? array('XmlReporter') : array('TextReporter'); + $reporter = &new SelectiveReporter(SimpleTest::preferred($interfaces), $parser->getTestCase(), $parser->getTest()); + } + else { + $reporter = &new SelectiveReporter(SimpleTest::preferred('HtmlReporter'), @$_GET['c'], @$_GET['t']); + } + $this->SimpleReporterDecorator($reporter); + } +} diff --git a/modules/simpletest/drupal_reporter.php b/modules/simpletest/drupal_reporter.php new file mode 100644 index 000000000..8885dfbe4 --- /dev/null +++ b/modules/simpletest/drupal_reporter.php @@ -0,0 +1,257 @@ +<?php +// $Id$ + +/** + * Minimal drupal displayer. Accumulates output to $_output. + * Based on HtmlReporter by Marcus Baker + */ +class DrupalReporter extends SimpleReporter { + var $_output_error = ''; + var $_character_set; + var $_fails_stack = array(0); + var $_exceptions_stack = array(0); + var $_passes_stack = array(0); + var $_progress_stack = array(0); + var $test_info_stack = array(); + var $_output_stack_id = -1; + var $form; + var $form_depth = array(); + var $current_field_set = array(); + var $content_count = 0; + var $weight = -10; + var $test_stack = array(); + + function DrupalReporter($character_set = 'ISO-8859-1') { + $this->SimpleReporter(); + drupal_add_css(drupal_get_path('module', 'simpletest') .'/simpletest.css'); + $this->_character_set = $character_set; + } + + /** + * Paints the top of the web page setting the + * title to the name of the starting test. + * @param string $test_name Name class of test. + * @access public + **/ + function paintHeader($test_name) { + + } + + /** + * Paints the end of the test with a summary of + * the passes and failures. + * @param string $test_name Name class of test. + * @access public + */ + function paintFooter($test_name) { + $ok = ($this->getFailCount() + $this->getExceptionCount() == 0); + $class = $ok ? 'simpletest-pass' : 'simpletest-fail'; + $this->writeContent('<strong>' . $this->getPassCount() . '</strong> passes, <strong>' . $this->getFailCount() . '</strong> fails and <strong>' . $this->getExceptionCount() . '<strong> exceptions.'); + } + + /** + * Paints the test passes + * @param string $message Failure message displayed in + * the context of the other tests. + * @access public + **/ + function paintPass($message, $group) { + parent::paintPass($message); + if ($group == 'Other') { + $group = t($group); + } + $this->test_stack[] = array( + 'data' => array($message, "<strong>[$group]</strong>", t('Pass'), theme('image', 'misc/watchdog-ok.png')), + 'class' => 'simpletest-pass', + ); + } + + /** + * Paints the test failure with a breadcrumbs + * trail of the nesting test suites below the + * top level test. + * @param string $message Failure message displayed in + * the context of the other tests. + * @access public + */ + function paintFail($message, $group) { + parent::paintFail($message); + if ($group == 'Other') { + $group = t($group); + } + $this->test_stack[] = array( + 'data' => array($message, "<strong>[$group]</strong>", t('Fail'), theme('image', 'misc/watchdog-error.png')), + 'class' => 'simpletest-fail', + ); + } + + + /** + * Paints a PHP error or exception. + * @param string $message Message is ignored. + * @access public + **/ + function paintError($message) { + parent::paintError($message); + $this->test_stack[] = array( + 'data' => array($message, '<strong>[PHP]</strong>', t('Exception'), theme('image', 'misc/watchdog-warning.png')), + 'class' => 'simpletest-exception', + ); + } + + /** + * Paints the start of a group test. Will also paint + * the page header and footer if this is the + * first test. Will stash the size if the first + * start. + * @param string $test_name Name of test that is starting. + * @param integer $size Number of test cases starting. + * @access public + */ + function paintGroupStart($test_name, $size, $extra = '') { + $this->_progress_stack[] = $this->_progress; + $this->_progress = 0; + $this->_exceptions_stack[] = $this->_exceptions; + $this->_exceptions = 0; + $this->_fails_stack[] = $this->_fails; + $this->_fails = 0; + $this->_passes_stack[] = $this->_passes; + $this->_passes = 0; + $this->form_depth[] = $test_name; + $this->writeToLastField($this->form, array( + '#type' => 'fieldset', + '#title' => $test_name, + '#weight' => $this->weight++, + ), $this->form_depth); + + if (! isset($this->_size)) { + $this->_size = $size; + } + + if (($c = count($this->test_info_stack)) > 0) { + $info = $this->test_info_stack[$c - 1]; + $this->writeContent('<strong>' . $info['name'] . '</strong>: ' . $info['description'], $this->getParentWeight() ); + } + + $this->_test_stack[] = $test_name; + } + + function paintCaseStart($test_name) { + $this->_progress++; + $this->paintGroupStart($test_name, 1); + } + + + /** + * Paints the end of a group test. Will paint the page + * footer if the stack of tests has unwound. + * @param string $test_name Name of test that is ending. + * @param integer $progress Number of test cases ending. + * @access public + */ + function paintGroupEnd($test_name) { + array_pop($this->_test_stack); + $ok = ($this->getFailCount() + $this->getExceptionCount() == 0); + $class = $ok ? "simpletest-pass" : "simpletest-fail"; + $parent_weight = $this->getParentWeight() - 0.5; + /* Exception for the top groups, no subgrouping for singles */ + if (($this->_output_stack_id == 2) && ($this->_output_stack[$this->_output_stack_id]['size'] == 1)) { + $this->writeContent(format_plural($this->getTestCaseProgress(), '1 test case complete: ', '@count test cases complete: '), -10); + $parent_weight = $this->getParentWeight() - 0.5; + $this->writeContent('<strong>' . $this->getPassCount() . '</strong> passes, <strong>' . $this->getFailCount() . '</strong> fails and <strong>' . $this->getExceptionCount() . '</strong> exceptions.', $parent_weight, $class); + array_pop($this->form_depth); + } + else { + $collapsed = $ok ? TRUE : FALSE; + if ($this->getTestCaseProgress()) { + $this->writeContent(format_plural($this->getTestCaseProgress(), '1 test case complete: ', '@count test cases complete: '), -10); + $use_grouping = FALSE; + } + else { + $use_grouping = TRUE; + } + $write = array('#collapsible' => $use_grouping, '#collapsed' => $collapsed); + $this->writeToLastField($this->form, $write, $this->form_depth); + $this->writeContent('<strong>' . $this->getPassCount() . '</strong> passes, <strong>' . $this->getFailCount() . '</strong> fails and <strong>' . $this->getExceptionCount() . '</strong> exceptions.', $parent_weight, $class); + if (count($this->test_stack) != 0) { + $this->writeContent(theme('table', array(), $this->test_stack)); + $this->test_stack = array(); + } + array_pop($this->form_depth); + } + + $this->_progress += array_pop($this->_progress_stack); + $this->_exceptions += array_pop($this->_exceptions_stack); + $this->_fails += array_pop($this->_fails_stack); + $this->_passes += array_pop($this->_passes_stack); + } + + function paintCaseEnd($test_name) { + $this->paintGroupEnd($test_name); + } + + /** + * Could be extended to show more headers or whatever? + **/ + function getOutput() { + return drupal_get_form('unit_tests', $this); + } + + /** + * Recursive function that writes attr to the deepest array + */ + function writeToLastField(&$form, $attr, $keys) { + while(count($keys) != 0) { + $value = array_shift($keys); + if (isset($form[$value])) { + if (count($keys) == 0) { + $form[$value] += $attr; + } + else { + $this->writeToLastField($form[$value], $attr, $keys); + } + $keys = array(); + } + else { + $form[$value] = $attr; + } + } + } + + /** + * writes $msg into the deepest fieldset + * @param $msg content to write + */ + function writeContent($msg, $weight = NULL, $class = 'simpletest') { + if (!$weight) { + $weight = $this->weight++; + } + $write['content'.$this->content_count++] = array( + '#value' => '<div class=' . $class .'>' . $msg . '</div>', + '#weight' => $weight, + ); + $this->writeToLastField($this->form, $write, $this->form_depth); + } + + /** + * Retrieves weight of the currently deepest fieldset + */ + function getParentWeight($form = NULL, $keys = NULL ) { + if (!isset($form)) { + $form = $this->form; + } + if (!isset($keys)) { + $keys = $this->form_depth; + } + if(count($keys) != 0) { + $value = array_shift($keys); + return $this->getParentWeight($form[$value], $keys); + } + return $form['#weight']; + } +} + +function unit_tests($args, $reporter) { + return $reporter->form['Drupal Unit Tests']; +} +?>
\ No newline at end of file diff --git a/modules/simpletest/drupal_test_suite.php b/modules/simpletest/drupal_test_suite.php new file mode 100644 index 000000000..bbc33e220 --- /dev/null +++ b/modules/simpletest/drupal_test_suite.php @@ -0,0 +1,121 @@ +<?php +// $Id$ + +/** + * Implementes getTestInstances to allow access to the test objects from outside + */ +class DrupalTestSuite extends TestSuite { + var $_cleanupModules = array(); + + function DrupalTestSuite($label) { + $this->TestSuite($label); + } + + /** + * @return array of instantiated tests that this GroupTests holds + */ + function getTestInstances() { + for ($i = 0, $count = count($this->_test_cases); $i < $count; $i++) { + if (is_string($this->_test_cases[$i])) { + $class = $this->_test_cases[$i]; + $this->_test_cases[$i] = &new $class(); + } + } + return $this->_test_cases; + } +} + +class DrupalTests extends DrupalTestSuite { + /** + * Constructor + * @param array $class_list list containing the classes of tests to be processed + * default: NULL - run all tests + */ + function DrupalTests($class_list = NULL) { + static $classes; + $this->DrupalTestSuite('Drupal Unit Tests'); + + /* Tricky part to avoid double inclusion */ + if (!$classes) { + + $files = array(); + foreach (array_keys(module_rebuild_cache()) as $module) { + $module_path = drupal_get_path('module', $module); + $test = $module_path . "/$module.test"; + if (file_exists($test)) { + $files[] = $test; + } + } + + $existing_classes = get_declared_classes(); + foreach ($files as $file) { + include_once($file); + } + $classes = array_diff(get_declared_classes(), $existing_classes); + } + if (!is_null($class_list)) { + $classes = $class_list; + } + if (count($classes) == 0) { + drupal_set_message('No test cases found.', 'error'); + return; + } + $groups = array(); + foreach ($classes as $class) { + if (!is_subclass_of($class, 'DrupalWebTestCase') && !is_subclass_of($class, 'DrupalUnitTestCase')) { + continue; + } + $this->_addClassToGroups($groups, $class); + } + foreach ($groups as $group_name => $group) { + $group_test = &new DrupalTestSuite($group_name); + foreach ($group as $key => $v) { + $group_test->addTestCase($group[$key]); + } + $this->addTestCase($group_test); + } + } + + /** + * Adds a class to a groups array specified by the getInfo of the group + * @param array $groups Group of categorized tests + * @param string $class Name of a class + */ + function _addClassToGroups(&$groups, $class) { + $test = &new $class(); + if (method_exists($test, 'getInfo')) { + $info = $test->getInfo(); + $groups[$info['group']][] = $test; + } + } + + /** + * Invokes run() on all of the held test cases, instantiating + * them if necessary. + * The Drupal version uses paintHeader instead of paintGroupStart + * to avoid collapsing of the very top level. + * + * @param SimpleReporter $reporter Current test reporter. + * @access public + */ + function run(&$reporter) { + cache_clear_all(); + @set_time_limit(0); + ignore_user_abort(TRUE); + + // Disable devel output, check simpletest settings page + if (!variable_get('simpletest_devel', FALSE)) { + $GLOBALS['devel_shutdown'] = FALSE; + } + + $result = parent::run($reporter); + + // Restores modules + foreach ($this->_cleanupModules as $name => $status) { + db_query("UPDATE {system} SET status = %d WHERE name = '%s' AND type = 'module'", $status, $name); + } + $this->_cleanupModules = array(); + + return $result; + } +} diff --git a/modules/simpletest/drupal_unit_test_case.php b/modules/simpletest/drupal_unit_test_case.php new file mode 100644 index 000000000..7a7807095 --- /dev/null +++ b/modules/simpletest/drupal_unit_test_case.php @@ -0,0 +1,97 @@ +<?php +// $Id$ + +/** + * Test case Drupal unit tests. + */ +class DrupalUnitTestCase extends UnitTestCase { + protected $created_temp_environment = FALSE; + protected $db_prefix_original; + protected $original_file_directory; + + /** + * Retrieve the test information from getInfo(). + * + * @param string $label Name of the test to be used by the SimpleTest library. + */ + function __construct($label = NULL) { + if (!$label) { + if (method_exists($this, 'getInfo')) { + $info = $this->getInfo(); + $label = $info['name']; + } + } + parent::__construct($label); + } + + /** + * Generates a random database prefix and runs the install scripts on the prefixed database. + * After installation many caches are flushed and the internal browser is setup so that the page + * requests will run on the new prefix. A temporary files directory is created with the same name + * as the database prefix. + * + * @param ... List modules to enable. + */ + public function setUp() { + parent::setUp(); + } + + /** + * Create a temporary environment for tests to take place in so that changes + * will be reverted and other tests won't be affected. + * + * Generates a random database prefix and runs the install scripts on the prefixed database. + * After installation many caches are flushed and the internal browser is setup so that the page + * requests will run on the new prefix. A temporary files directory is created with the same name + * as the database prefix. + */ + protected function createTempEnvironment() { + global $db_prefix, $simpletest_ua_key; + $this->created_temp_environment = TRUE; + if ($simpletest_ua_key) { + $this->db_prefix_original = $db_prefix; + $clean_url_original = variable_get('clean_url', 0); + $db_prefix = 'simpletest'. mt_rand(1000, 1000000); + include_once './includes/install.inc'; + drupal_install_system(); + $modules = array_unique(array_merge(func_get_args(), drupal_verify_profile('default', 'en'))); + drupal_install_modules($modules); + $this->_modules = drupal_map_assoc($modules); + $this->_modules['system'] = 'system'; + $task = 'profile'; + default_profile_tasks($task, ''); + menu_rebuild(); + actions_synchronize(); + _drupal_flush_css_js(); + variable_set('install_profile', 'default'); + variable_set('install_task', 'profile-finished'); + variable_set('clean_url', $clean_url_original); + + // Use temporary files directory with the same prefix as database. + $this->original_file_directory = file_directory_path(); + variable_set('file_directory_path', file_directory_path() .'/'. $db_prefix); + file_check_directory(file_directory_path(), TRUE); // Create the files directory. + } + } + + /** + * Delete created files and temporary files directory, delete the tables created by setUp(), + * and reset the database prefix. + */ + public function tearDown() { + global $db_prefix; + if ($this->created_temp_environment && preg_match('/simpletest\d+/', $db_prefix)) { + // Delete temporary files directory and reset files directory path. + simpletest_clean_temporary_directory(file_directory_path()); + variable_set('file_directory_path', $this->original_file_directory); + + $schema = drupal_get_schema(NULL, TRUE); + $ret = array(); + foreach ($schema as $name => $table) { + db_drop_table($ret, $name); + } + $db_prefix = $this->db_prefix_original; + } + parent::tearDown(); + } +} diff --git a/modules/simpletest/drupal_web_test_case.php b/modules/simpletest/drupal_web_test_case.php new file mode 100644 index 000000000..304a2d214 --- /dev/null +++ b/modules/simpletest/drupal_web_test_case.php @@ -0,0 +1,1051 @@ +<?php +// $Id$ + +/** + * Test case for typical Drupal tests. + */ +class DrupalWebTestCase extends UnitTestCase { + protected $_logged_in = FALSE; + protected $_content; + protected $plain_text; + protected $ch; + protected $_modules = array(); + // We do not reuse the cookies in further runs, so we do not need a file + // but we still need cookie handling, so we set the jar to NULL + protected $cookie_file = NULL; + // Overwrite this any time to supply cURL options as necessary, + // DrupalTestCase itself never sets this but always obeys whats set. + protected $curl_options = array(); + protected $original_file_directory; + + /** + * Retrieve the test information from getInfo(). + * + * @param string $label Name of the test to be used by the SimpleTest library. + */ + function __construct($label = NULL) { + if (!$label) { + if (method_exists($this, 'getInfo')) { + $info = $this->getInfo(); + $label = $info['name']; + } + } + parent::__construct($label); + } + + /** + * Creates a node based on default settings. + * + * @param settings + * An assocative array of settings to change from the defaults, keys are + * node properties, for example 'body' => 'Hello, world!'. + * @return object Created node object. + */ + function drupalCreateNode($settings = array()) { + // Populate defaults array + $defaults = array( + 'body' => $this->randomName(32), + 'title' => $this->randomName(8), + 'comment' => 2, + 'changed' => time(), + 'format' => FILTER_FORMAT_DEFAULT, + 'moderate' => 0, + 'promote' => 0, + 'revision' => 1, + 'log' => '', + 'status' => 1, + 'sticky' => 0, + 'type' => 'page', + 'revisions' => NULL, + 'taxonomy' => NULL, + ); + $defaults['teaser'] = $defaults['body']; + // If we already have a node, we use the original node's created time, and this + if (isset($defaults['created'])) { + $defaults['date'] = format_date($defaults['created'], 'custom', 'Y-m-d H:i:s O'); + } + if (empty($settings['uid'])) { + global $user; + $defaults['uid'] = $user->uid; + } + $node = ($settings + $defaults); + $node = (object)$node; + + node_save($node); + + // small hack to link revisions to our test user + db_query('UPDATE {node_revisions} SET uid = %d WHERE vid = %d', $node->uid, $node->vid); + return $node; + } + + /** + * Creates a custom content type based on default settings. + * + * @param settings + * An array of settings to change from the defaults. + * Example: 'type' => 'foo'. + * @return object Created content type. + */ + function drupalCreateContentType($settings = array()) { + // find a non-existent random type name. + do { + $name = strtolower($this->randomName(3, 'type_')); + } while (node_get_types('type', $name)); + + // Populate defaults array + $defaults = array( + 'type' => $name, + 'name' => $name, + 'description' => '', + 'help' => '', + 'min_word_count' => 0, + 'title_label' => 'Title', + 'body_label' => 'Body', + 'has_title' => 1, + 'has_body' => 1, + ); + // imposed values for a custom type + $forced = array( + 'orig_type' => '', + 'old_type' => '', + 'module' => 'node', + 'custom' => 1, + 'modified' => 1, + 'locked' => 0, + ); + $type = $forced + $settings + $defaults; + $type = (object)$type; + + node_type_save($type); + node_types_rebuild(); + + return $type; + } + + /** + * Get a list files that can be used in tests. + * + * @param string $type File type, possible values: 'binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'. + * @param integer $size File size in bytes to match. Please check the tests/files folder. + * @return array List of files that match filter. + */ + function drupalGetTestFiles($type, $size = NULL) { + $files = array(); + + // Make sure type is valid. + if (in_array($type, array('binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'))) { + // Use original file directory instead of one created during setUp(). + $path = $this->original_file_directory .'/simpletest'; + $files = file_scan_directory($path, $type .'\-.*'); + + // If size is set then remove any files that are not of that size. + if ($size !== NULL) { + foreach ($files as $file) { + $stats = stat($file->filename); + if ($stats['size'] != $size) { + unset($files[$file->filename]); + } + } + } + } + usort($files, array($this, 'drupalCompareFiles')); + return $files; + } + + /** + * Compare two files based on size. + */ + function drupalCompareFiles($file1, $file2) { + if (stat($file1->filename) > stat($file2->filename)) { + return 1; + } + return -1; + } + + /** + * Generates a random string. + * + * @param integer $number Number of characters in length to append to the prefix. + * @param string $prefix Prefix to use. + * @return string Randomly generated string. + */ + function randomName($number = 4, $prefix = 'simpletest_') { + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'; + for ($x = 0; $x < $number; $x++) { + $prefix .= $chars{mt_rand(0, strlen($chars) - 1)}; + if ($x == 0) { + $chars .= '0123456789'; + } + } + return $prefix; + } + + /** + * Enables a drupal module in the test database. Any module that is not + * part of the required core modules needs to be enabled in order to use + * it in a test. + * + * @param string $name Name of the module to enable. + * @return boolean Success. + */ + function drupalModuleEnable($name) { + if (module_exists($name)) { + $this->pass(t('@name module already enabled', array('@name' => $name)), t('Module')); + return TRUE; + } + $this->_modules[$name] = $name; + $form_state['values'] = array('status' => $this->_modules, 'op' => t('Save configuration')); + drupal_execute('system_modules', $form_state); + + //rebuilding all caches + drupal_rebuild_theme_registry(); + node_types_rebuild(); + menu_rebuild(); + cache_clear_all('schema', 'cache'); + module_rebuild_cache(); + } + + /** + * Disables a drupal module in the test database. + * + * @param string $name Name of the module. + * @return boolean Success. + * @see drupalModuleEnable() + */ + function drupalModuleDisable($name) { + if (!module_exists($name)) { + $this->pass(t('@name module already disabled', array('@name' => $name)), t('Module')); + return TRUE; + } + unset($this->_modules[$key]); + $form_state['values'] = array('status' => $this->_modules, 'op' => t('Save configuration')); + drupal_execute('system_modules', $form_state); + + //rebuilding all caches + drupal_rebuild_theme_registry(); + node_types_rebuild(); + menu_rebuild(); + cache_clear_all('schema', 'cache'); + module_rebuild_cache(); + } + + /** + * Create a user with a given set of permissions. The permissions correspond to the + * names given on the privileges page. + * + * @param array $permissions Array of permission names to assign to user. + * @return A fully loaded user object with pass_raw property, or FALSE if account + * creation fails. + */ + function drupalCreateUser($permissions = NULL) { + // Create a role with the given permission set. + $rid = $this->_drupalCreateRole($permissions); + if (!$rid) { + return FALSE; + } + + // Create a user assigned to that role. + $edit = array(); + $edit['name'] = $this->randomName(); + $edit['mail'] = $edit['name'] .'@example.com'; + $edit['roles'] = array($rid => $rid); + $edit['pass'] = user_password(); + $edit['status'] = 1; + + $account = user_save('', $edit); + + $this->assertTrue(!empty($account->uid), t('User created with name %name and pass %pass', array('%name' => $edit['name'], '%pass' => $edit['pass'])), t('User login')); + if (empty($account->uid)) { + return FALSE; + } + + // Add the raw password so that we can log in as this user. + $account->pass_raw = $edit['pass']; + return $account; + } + + /** + * Internal helper function; Create a role with specified permissions. + * + * @param array $permissions Array of permission names to assign to role. + * @return integer Role ID of newly created role, or FALSE if role creation failed. + */ + private function _drupalCreateRole($permissions = NULL) { + // Generate string version of permissions list. + if ($permissions === NULL) { + $permission_string = 'access comments, access content, post comments, post comments without approval'; + } else { + $permission_string = implode(', ', $permissions); + } + + // Create new role. + $role_name = $this->randomName(); + db_query("INSERT INTO {role} (name) VALUES ('%s')", $role_name); + $role = db_fetch_object(db_query("SELECT * FROM {role} WHERE name = '%s'", $role_name)); + $this->assertTrue($role, t('Created role of name: @role_name, id: @rid', array('@role_name' => $role_name, '@rid' => (isset($role->rid) ? $role->rid : t('-n/a-')))), t('Role')); + if ($role && !empty($role->rid)) { + // Assign permissions to role and mark it for clean-up. + db_query("INSERT INTO {permission} (rid, perm) VALUES (%d, '%s')", $role->rid, $permission_string); + $this->assertTrue(db_affected_rows(), t('Created permissions: @perms', array('@perms' => $permission_string)), t('Role')); + return $role->rid; + } + else { + return FALSE; + } + } + + /** + * Logs in a user with the internal browser. If already logged in then logs the current + * user out before logging in the specified user. If no user is specified then a new + * user will be created and logged in. + * + * @param object $user User object representing the user to login. + * @return object User that was logged in. Useful if no user was passed in order + * to retreive the created user. + */ + function drupalLogin($user = NULL) { + if ($this->_logged_in) { + $this->drupalLogout(); + } + + if (!isset($user)) { + $user = $this->_drupalCreateRole(); + } + + $edit = array( + 'name' => $user->name, + 'pass' => $user->pass_raw + ); + $this->drupalPost('user', $edit, t('Log in')); + + $pass = $this->assertText($user->name, t('Found name: %name', array('%name' => $user->name)), t('User login')); + $pass = $pass && $this->assertNoText(t('The username %name has been blocked.', array('%name' => $user->name)), t('No blocked message at login page'), t('User login')); + $pass = $pass && $this->assertNoText(t('The name %name is a reserved username.', array('%name' => $user->name)), t('No reserved message at login page'), t('User login')); + + $this->_logged_in = $pass; + + return $user; + } + + /* + * Logs a user out of the internal browser, then check the login page to confirm logout. + */ + function drupalLogout() { + // Make a request to the logout page. + $this->drupalGet('logout'); + + // Load the user page, the idea being if you were properly logged out you should be seeing a login screen. + $this->drupalGet('user'); + $pass = $this->assertField('name', t('Username field found.'), t('Logout')); + $pass = $pass && $this->assertField('pass', t('Password field found.'), t('Logout')); + + $this->_logged_in = !$pass; + } + + /** + * Generates a random database prefix and runs the install scripts on the prefixed database. + * After installation many caches are flushed and the internal browser is setup so that the page + * requests will run on the new prefix. A temporary files directory is created with the same name + * as the database prefix. + * + * @param ... List modules to enable. + */ + function setUp() { + global $db_prefix; + $this->db_prefix_original = $db_prefix; + $clean_url_original = variable_get('clean_url', 0); + $db_prefix = 'simpletest'. mt_rand(1000, 1000000); + include_once './includes/install.inc'; + drupal_install_system(); + $modules = array_unique(array_merge(func_get_args(), drupal_verify_profile('default', 'en'))); + drupal_install_modules($modules); + $this->_modules = drupal_map_assoc($modules); + $this->_modules['system'] = 'system'; + $task = 'profile'; + default_profile_tasks($task, ''); + menu_rebuild(); + actions_synchronize(); + _drupal_flush_css_js(); + variable_set('install_profile', 'default'); + variable_set('install_task', 'profile-finished'); + variable_set('clean_url', $clean_url_original); + + // Use temporary files directory with the same prefix as database. + $this->original_file_directory = file_directory_path(); + variable_set('file_directory_path', file_directory_path() .'/'. $db_prefix); + file_check_directory(file_directory_path(), TRUE); // Create the files directory. + parent::setUp(); + } + + /** + * Delete created files and temporary files directory, delete the tables created by setUp(), + * and reset the database prefix. + */ + function tearDown() { + global $db_prefix; + if (preg_match('/simpletest\d+/', $db_prefix)) { + // Delete temporary files directory and reset files directory path. +// simpletest_clean_temporary_directory(file_directory_path()); + variable_set('file_directory_path', $this->original_file_directory); + + $schema = drupal_get_schema(NULL, TRUE); + $ret = array(); + foreach ($schema as $name => $table) { + db_drop_table($ret, $name); + } + $db_prefix = $this->db_prefix_original; + $this->_logged_in = FALSE; + $this->curlClose(); + } + parent::tearDown(); + } + + /** + * Set necessary reporter info. + */ + function run(&$reporter) { + $arr = array('class' => get_class($this)); + if (method_exists($this, 'getInfo')) { + $arr = array_merge($arr, $this->getInfo()); + } + $reporter->test_info_stack[] = $arr; + parent::run($reporter); + array_pop($reporter->test_info_stack); + } + + /** + * Initializes the cURL connection and gets a session cookie. + * + * This function will add authentaticon headers as specified in + * simpletest_httpauth_username and simpletest_httpauth_pass variables. + * Also, see the description of $curl_options among the properties. + */ + protected function curlConnect() { + global $base_url, $db_prefix; + if (!isset($this->ch)) { + $this->ch = curl_init(); + $curl_options = $this->curl_options + array( + CURLOPT_COOKIEJAR => $this->cookie_file, + CURLOPT_URL => $base_url, + CURLOPT_FOLLOWLOCATION => TRUE, + CURLOPT_RETURNTRANSFER => TRUE, + ); + if (preg_match('/simpletest\d+/', $db_prefix)) { + $curl_options[CURLOPT_USERAGENT] = $db_prefix; + } + if (!isset($curl_options[CURLOPT_USERPWD]) && ($auth = variable_get('simpletest_httpauth_username', ''))) { + if ($pass = variable_get('simpletest_httpauth_pass', '')) { + $auth .= ':'. $pass; + } + $curl_options[CURLOPT_USERPWD] = $auth; + } + return $this->curlExec($curl_options); + } + } + + /** + * Peforms a cURL exec with the specified options after calling curlConnect(). + * + * @param array $curl_options Custom cURL options. + * @return string Content returned from the exec. + */ + protected function curlExec($curl_options) { + $this->curlConnect(); + $url = empty($curl_options[CURLOPT_URL]) ? curl_getinfo($this->ch, CURLINFO_EFFECTIVE_URL) : $curl_options[CURLOPT_URL]; + curl_setopt_array($this->ch, $this->curl_options + $curl_options); + $this->_content = curl_exec($this->ch); + $this->plain_text = FALSE; + $this->elements = FALSE; + $this->assertTrue($this->_content, t('!method to !url, response is !length bytes.', array('!method' => isset($curl_options[CURLOPT_POSTFIELDS]) ? 'POST' : 'GET', '!url' => $url, '!length' => strlen($this->_content))), t('Browser')); + return $this->_content; + } + + /** + * Close the cURL handler and unset the handler. + */ + protected function curlClose() { + if (isset($this->ch)) { + curl_close($this->ch); + unset($this->ch); + } + } + + /** + * Parse content returned from curlExec using DOM and simplexml. + * + * @return SimpleXMLElement A SimpleXMLElement or FALSE on failure. + */ + protected function parse() { + if (!$this->elements) { + // DOM can load HTML soup. But, HTML soup can throw warnings, supress + // them. + @$htmlDom = DOMDocument::loadHTML($this->_content); + if ($htmlDom) { + $this->assertTrue(TRUE, t('Valid HTML found on "@path"', array('@path' => $this->getUrl())), t('Browser')); + // It's much easier to work with simplexml than DOM, luckily enough + // we can just simply import our DOM tree. + $this->elements = simplexml_import_dom($htmlDom); + } + } + return $this->elements; + } + + /** + * Retrieves a Drupal path or an absolute path. + * + * @param $path string Drupal path or url to load into internal browser + * @param array $options Options to be forwarded to url(). + * @return The retrieved HTML string, also available as $this->drupalGetContent() + */ + function drupalGet($path, $options = array()) { + $options['absolute'] = TRUE; + return $this->curlExec(array(CURLOPT_URL => url($path, $options))); + } + + /** + * Do a post request on a drupal page. + * It will be done as usual post request with SimpleBrowser + * By $reporting you specify if this request does assertions or not + * Warning: empty ("") returns will cause fails with $reporting + * + * @param string $path + * Location of the post form. Either a Drupal path or an absolute path or + * NULL to post to the current page. + * @param array $edit + * Field data in an assocative array. Changes the current input fields + * (where possible) to the values indicated. A checkbox can be set to + * TRUE to be checked and FALSE to be unchecked. + * @param string $submit + * Untranslated value, id or name of the submit button. + * @param $tamper + * If this is set to TRUE then you can post anything, otherwise hidden and + * nonexistent fields are not posted. + */ + function drupalPost($path, $edit, $submit, $tamper = FALSE) { + $submit_matches = FALSE; + if (isset($path)) { + $html = $this->drupalGet($path); + } + if ($this->parse()) { + $edit_save = $edit; + // Let's iterate over all the forms. + $forms = $this->elements->xpath('//form'); + foreach ($forms as $form) { + if ($tamper) { + // @TODO: this will be Drupal specific. One needs to add the build_id + // and the token to $edit then $post that. + } + else { + // We try to set the fields of this form as specified in $edit. + $edit = $edit_save; + $post = array(); + $upload = array(); + $submit_matches = $this->handleForm($post, $edit, $upload, $submit, $form); + $action = isset($form['action']) ? $this->getAbsoluteUrl($form['action']) : $this->getUrl(); + } + // We post only if we managed to handle every field in edit and the + // submit button matches; + if (!$edit && $submit_matches) { + // This part is not pretty. There is very little I can do. + if ($upload) { + foreach ($post as &$value) { + if (strlen($value) > 0 && $value[0] == '@') { + $this->fail(t("Can't upload and post a value starting with @")); + return FALSE; + } + } + foreach ($upload as $key => $file) { + $post[$key] = '@'. realpath($file); + } + } + else { + $post_array = $post; + $post = array(); + foreach ($post_array as $key => $value) { + // Whethet this needs to be urlencode or rawurlencode, is not + // quite clear, but this seems to be the better choice. + $post[] = urlencode($key) .'='. urlencode($value); + } + $post = implode('&', $post); + } + return $this->curlExec(array(CURLOPT_URL => $action, CURLOPT_POSTFIELDS => $post)); + } + } + // We have not found a form which contained all fields of $edit. + $this->fail(t('Found the requested form')); + $this->assertTrue($submit_matches, t('Found the @submit button', array('@submit' => $submit))); + foreach ($edit as $name => $value) { + $this->fail(t('Failed to set field @name to @value', array('@name' => $name, '@value' => $value))); + } + } + } + + /** + * Handle form input related to drupalPost(). Ensure that the specified fields + * exist and attempt to create POST data in the correct manor for the particular + * field type. + * + * @param array $post Reference to array of post values. + * @param array $edit Reference to array of edit values to be checked against the form. + * @param string $submit Form submit button value. + * @param array $form Array of form elements. + * @return boolean Submit value matches a valid submit input in the form. + */ + protected function handleForm(&$post, &$edit, &$upload, $submit, $form) { + // Retrieve the form elements. + $elements = $form->xpath('.//input|.//textarea|.//select'); + $submit_matches = FALSE; + foreach ($elements as $element) { + // SimpleXML objects need string casting all the time. + $name = (string)$element['name']; + // This can either be the type of <input> or the name of the tag itself + // for <select> or <textarea>. + $type = isset($element['type']) ? (string)$element['type'] : $element->getName(); + $value = isset($element['value']) ? (string)$element['value'] : ''; + $done = FALSE; + if (isset($edit[$name])) { + switch ($type) { + case 'text': + case 'textarea': + case 'password': + $post[$name] = $edit[$name]; + unset($edit[$name]); + break; + case 'radio': + if ($edit[$name] == $value) { + $post[$name] = $edit[$name]; + unset($edit[$name]); + } + break; + case 'checkbox': + // To prevent checkbox from being checked.pass in a FALSE, + // otherwise the checkbox will be set to its value regardless + // of $edit. + if ($edit[$name] === FALSE) { + unset($edit[$name]); + continue 2; + } + else { + unset($edit[$name]); + $post[$name] = $value; + } + break; + case 'select': + $new_value = $edit[$name]; + $index = 0; + $key = preg_replace('/\[\]$/', '', $name); + $options = $this->getAllOptions($element); + foreach ($options as $option) { + if (is_array($new_value)) { + $option_value= (string)$option['value']; + if (in_array($option_value, $new_value)) { + $post[$key .'['. $index++ .']'] = $option_value; + $done = TRUE; + unset($edit[$name]); + } + } + elseif ($new_value == $option['value']) { + $post[$name] = $new_value; + unset($edit[$name]); + $done = TRUE; + } + } + break; + case 'file': + $upload[$name] = $edit[$name]; + unset($edit[$name]); + break; + } + } + if (!isset($post[$name]) && !$done) { + switch ($type) { + case 'textarea': + $post[$name] = (string)$element; + break; + case 'select': + $single = empty($element['multiple']); + $first = TRUE; + $index = 0; + $key = preg_replace('/\[\]$/', '', $name); + $options = $this->getAllOptions($element); + foreach ($options as $option) { + // For single select, we load the first option, if there is a + // selected option that will overwrite it later. + if ($option['selected'] || ($first && $single)) { + $first = FALSE; + if ($single) { + $post[$name] = (string)$option['value']; + } + else { + $post[$key .'['. $index++ .']'] = (string)$option['value']; + } + } + } + break; + case 'file': + break; + case 'submit': + case 'image': + if ($submit == $value) { + $post[$name] = $value; + $submit_matches = TRUE; + } + break; + case 'radio': + case 'checkbox': + if (!isset($element['checked'])) { + break; + } + // Deliberate no break. + default: + $post[$name] = $value; + } + } + } + return $submit_matches; + } + + /** + * Get all option elements, including nested options, in a select. + * + * @param SimpleXMLElement $element + * @return array Option elements in select. + */ + private function getAllOptions(SimpleXMLElement $element) { + $options = array(); + // Add all options items. + foreach ($element->option as $option) { + $options[] = $option; + } + + // Search option group children. + if (isset($element->optgroup)) { + $options = array_merge($options, $this->getAllOptions($element->optgroup)); + } + return $options; + } + + /** + * Follows a link by name. + * + * Will click the first link found with this link text by default, or a + * later one if an index is given. Match is case insensitive with + * normalized space. The label is translated label. There is an assert + * for successful click. + * WARNING: Assertion fails on empty ("") output from the clicked link. + * + * @param string $label Text between the anchor tags. + * @param integer $index Link position counting from zero. + * @param boolean $reporting Assertions or not. + * @return boolean/string Page on success. + */ + function clickLink($label, $index = 0) { + $url_before = $this->getUrl(); + $ret = FALSE; + if ($this->parse()) { + $urls = $this->elements->xpath('//a[text()="'. $label .'"]'); + if (isset($urls[$index])) { + $url_target = $this->getAbsoluteUrl($urls[$index]['href']); + $curl_options = array(CURLOPT_URL => $url_target); + $ret = $this->curlExec($curl_options); + } + $this->assertTrue($ret, t('Clicked link !label (!url_target) from !url_before', array('!label' => $label, '!url_target' => $url_target, '!url_before' => $url_before)), t('Browser')); + } + return $ret; + } + + /** + * Takes a path and returns an absolute path. + * + * @param @path + * The path, can be a Drupal path or a site-relative path. It might have a + * query, too. Can even be an absolute path which is just passed through. + * @return + * An absolute path. + */ + function getAbsoluteUrl($path) { + $options = array('absolute' => TRUE); + $parts = parse_url($path); + // This is more crude than the menu_is_external but enough here. + if (empty($parts['host'])) { + $path = $parts['path']; + $base_path = base_path(); + $n = strlen($base_path); + if (substr($path, 0, $n) == $base_path) { + $path = substr($path, $n); + } + if (isset($parts['query'])) { + $options['query'] = $parts['query']; + } + $path = url($path, $options); + } + return $path; + } + + /** + * Get the current url from the cURL handler. + * + * @return string current url. + */ + function getUrl() { + return curl_getinfo($this->ch, CURLINFO_EFFECTIVE_URL); + } + + /** + * Gets the current raw HTML of requested page. + */ + function drupalGetContent() { + return $this->_content; + } + + /** + * Pass if the raw text IS found on the loaded page, fail otherwise. Raw text + * refers to the raw HTML that the page generated. + * + * @param string $raw Raw string to look for. + * @param string $message Message to display. + * @return boolean TRUE on pass. + */ + function assertRaw($raw, $message = "%s", $group = 'Other') { + return $this->assertFalse(strpos($this->_content, $raw) === FALSE, $message, $group); + } + + /** + * Pass if the raw text is NOT found on the loaded page, fail otherwise. Raw text + * refers to the raw HTML that the page generated. + * + * @param string $raw Raw string to look for. + * @param string $message Message to display. + * @return boolean TRUE on pass. + */ + function assertNoRaw($raw, $message = "%s", $group = 'Other') { + return $this->assertTrue(strpos($this->_content, $raw) === FALSE, $message, $group); + } + + /** + * Pass if the text IS found on the text version of the page. The text version + * is the equivilent of what a user would see when viewing through a web browser. + * In other words the HTML has been filtered out of the contents. + * + * @param string $raw Text string to look for. + * @param string $message Message to display. + * @return boolean TRUE on pass. + */ + function assertText($text, $message = '', $group = 'Other') { + return $this->assertTextHelper($text, $message, $group = 'Other', FALSE); + } + + /** + * Pass if the text is NOT found on the text version of the page. The text version + * is the equivilent of what a user would see when viewing through a web browser. + * In other words the HTML has been filtered out of the contents. + * + * @param string $raw Text string to look for. + * @param string $message Message to display. + * @return boolean TRUE on pass. + */ + function assertNoText($text, $message = '', $group = 'Other') { + return $this->assertTextHelper($text, $message, $group, TRUE); + } + + /** + * Filter out the HTML of the page and assert that the plain text us found. Called by + * the plain text assertions. + * + * @param string $text Text to look for. + * @param string $message Message to display. + * @param boolean $not_exists The assert to make in relation to the text's existance. + * @return boolean Assertion result. + */ + protected function assertTextHelper($text, $message, $group, $not_exists) { + if ($this->plain_text === FALSE) { + $this->plain_text = filter_xss($this->_content, array()); + } + if (!$message) { + $message = '"'. $text .'"'. ($not_exists ? ' not found.' : ' found.'); + } + return $this->assertTrue($not_exists == (strpos($this->plain_text, $text) === FALSE), $message, $group); + } + + /** + * Will trigger a pass if the Perl regex pattern is found in the raw content. + * + * @param string $pattern Perl regex to look for including the regex delimiters. + * @param string $message Message to display. + * @return boolean True if pass. + */ + function assertPattern($pattern, $message = '%s', $group = 'Other') { + return $this->assertTrue(preg_match($pattern, $this->drupalGetContent()), $message, $group); + } + + /** + * Will trigger a pass if the perl regex pattern is not present in raw content. + * + * @param string $pattern Perl regex to look for including the regex delimiters. + * @param string $message Message to display. + * @return boolean True if pass. + */ + function assertNoPattern($pattern, $message = '%s', $group = 'Other') { + return $this->assertFalse(preg_match($pattern, $this->drupalGetContent()), $message, $group); + } + + /** + * Pass if the page title is the given string. + * + * @param $title Text string to look for. + * @param $message Message to display. + * @return boolean TRUE on pass. + */ + function assertTitle($title, $message, $group = 'Other') { + return $this->assertTrue($this->parse() && $this->elements->xpath('//title[text()="'. $title .'"]'), $message, $group); + } + + /** + * Assert that a field exists in the current page by the given XPath. + * + * @param string $xpath XPath used to find the field. + * @param string $value Value of the field to assert. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertFieldByXPath($xpath, $value, $message, $group = 'Other') { + $fields = array(); + if ($this->parse()) { + $fields = $this->elements->xpath($xpath); + } + + // If value specified then check array for match. + $found = TRUE; + if ($value) { + $found = FALSE; + foreach ($fields as $field) { + if ($field['value'] == $value) { + $found = TRUE; + } + } + } + return $this->assertTrue($fields && $found, $message, $group); + } + + /** + * Assert that a field does not exists in the current page by the given XPath. + * + * @param string $xpath XPath used to find the field. + * @param string $value Value of the field to assert. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertNoFieldByXPath($xpath, $value, $message, $group = 'Other') { + $fields = array(); + if ($this->parse()) { + $fields = $this->elements->xpath($xpath); + } + + // If value specified then check array for match. + $found = TRUE; + if ($value) { + $found = FALSE; + foreach ($fields as $field) { + if ($field['value'] == $value) { + $found = TRUE; + } + } + } + return $this->assertFalse($fields && $found, $message, $group); + } + + /** + * Assert that a field exists in the current page with the given name and value. + * + * @param string $name Name of field to assert. + * @param string $value Value of the field to assert. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertFieldByName($name, $value = '', $message = '') { + return $this->assertFieldByXPath($this->_constructFieldXpath('name', $name), $value, $message ? $message : t('Found field by name @name', array('@name' => $name)), t('Browser')); + } + + /** + * Assert that a field does not exists in the current page with the given name and value. + * + * @param string $name Name of field to assert. + * @param string $value Value of the field to assert. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertNoFieldByName($name, $value = '', $message = '') { + return $this->assertNoFieldByXPath($this->_constructFieldXpath('name', $name), $value, $message ? $message : t('Did not find field by name @name', array('@name' => $name)), t('Browser')); + } + + /** + * Assert that a field exists in the current page with the given id and value. + * + * @param string $id Id of field to assert. + * @param string $value Value of the field to assert. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertFieldById($id, $value = '', $message = '') { + return $this->assertFieldByXPath($this->_constructFieldXpath('id', $id), $value, $message ? $message : t('Found field by id @id', array('@id' => $id)), t('Browser')); + } + + /** + * Assert that a field does not exists in the current page with the given id and value. + * + * @param string $id Id of field to assert. + * @param string $value Value of the field to assert. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertNoFieldById($id, $value = '', $message = '') { + return $this->assertNoFieldByXPath($this->_constructFieldXpath('id', $id), $value, $message ? $message : t('Did not find field by id @id', array('@id' => $id)), t('Browser')); + } + + /** + * Assert that a field exists in the current page with the given name or id. + * + * @param string $field Name or id of the field. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertField($field, $message = '', $group = 'Other') { + return $this->assertFieldByXPath($this->_constructFieldXpath('name', $field) .'|'. $this->_constructFieldXpath('id', $field), '', $message, $group); + } + + /** + * Assert that a field does not exists in the current page with the given name or id. + * + * @param string $field Name or id of the field. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertNoField($field, $message = '', $group = 'Other') { + return $this->assertNoFieldByXPath($this->_constructFieldXpath('name', $field) .'|'. $this->_constructFieldXpath('id', $field), '', $message, $group); + } + + /** + * Construct an XPath for the given set of attributes and value. + * + * @param array $attribute Field attributes. + * @param string $value Value of field. + * @return string XPath for specified values. + */ + function _constructFieldXpath($attribute, $value) { + return '//textarea[@'. $attribute .'="'. $value .'"]|//input[@'. $attribute .'="'. $value .'"]|//select[@'. $attribute .'="'. $value .'"]'; + } + + /** + * Assert the page responds with the specified response code. + * + * @param integer $code Reponse code. For example 200 is a successful page request. For + * a list of all codes see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html. + * @param string $message Message to display. + * @return boolean Assertion result. + */ + function assertResponse($code, $message = '') { + $curl_code = curl_getinfo($this->ch, CURLINFO_HTTP_CODE); + $match = is_array($code) ? in_array($curl_code, $code) : $curl_code == $code; + return $this->assertTrue($match, $message ? $message : t('HTTP response expected !code, actual !curl_code', array('!code' => $code, '!curl_code' => $curl_code)), t('Browser')); + } +} diff --git a/modules/simpletest/dumper.php b/modules/simpletest/dumper.php new file mode 100644 index 000000000..e2995cb21 --- /dev/null +++ b/modules/simpletest/dumper.php @@ -0,0 +1,80 @@ +<?php +// $Id$ + +/** + * Displays variables as text and does diffs. + */ +class SimpleDumper { + /** + * Renders a variable in a shorter form than print_r(). + * + * @param mixed $value Variable to render as a string. + * + * @return string Human readable string form. + * @access public + */ + function describeValue($value) { + $type = $this->getType($value); + switch ($type) { + case "Null": + return "NULL"; + + case "Bool": + return "Boolean: ". ($value ? "true" : "false"); + + case "Array": + return "Array: ". count($value) ." items"; + + case "Object": + return "Object: of ". get_class($value); + + case "String": + return "String: ". $this->clipString($value, 200); + + default: + return "$type: $value"; + } + } + + /** + * Gets the string representation of a type. + * @param mixed $value Variable to check against. + * @return string Type. + * @access public + */ + function getType($value) { + if (!isset($value)) { + return "Null"; + } + $functions = array('bool', 'string', 'integer', 'float', 'array', 'resource', 'object'); + foreach ($functions as $function) { + $function_name = 'is_'. $function; + if ($function_name($value)) { + return ucfirst($function); + } + } + return "Unknown"; + } + + /** + * Clips a string to a maximum length. + * @param string $value String to truncate. + * @param integer $size Minimum string size to show. + * @param integer $position Centre of string section. + * @return string Shortened version. + * @access public + */ + function clipString($value, $size, $position = 0) { + $length = strlen($value); + if ($length <= $size) { + return $value; + } + $position = min($position, $length); + $start = ($size / 2 > $position ? 0 : $position - $size / 2); + if ($start + $size > $length) { + $start = $length - $size; + } + $value = substr($value, $start, $size); + return ($start > 0 ? "..." : "") . $value . ($start + $size < $length ? "..." : ""); + } +} diff --git a/modules/simpletest/errors.php b/modules/simpletest/errors.php new file mode 100644 index 000000000..f186ce6ab --- /dev/null +++ b/modules/simpletest/errors.php @@ -0,0 +1,240 @@ +<?php +// $Id$ + +/** + * Extension that traps errors into an error queue. + * @package SimpleTest + * @subpackage UnitTester + */ +class SimpleErrorTrappingInvoker extends SimpleInvokerDecorator { + /** + * Stores the invoker to wrap. + * @param SimpleInvoker $invoker Test method runner. + */ + function SimpleErrorTrappingInvoker(&$invoker) { + $this->SimpleInvokerDecorator($invoker); + } + + /** + * Invokes a test method and dispatches any + * untrapped errors. Called back from + * the visiting runner. + * @param string $method Test method to call. + * @access public + */ + function invoke($method) { + $queue = &$this->_createErrorQueue(); + set_error_handler('SimpleTestErrorHandler'); + parent::invoke($method); + restore_error_handler(); + $queue->tally(); + } + + /** + * Wires up the error queue for a single test. + * @return SimpleErrorQueue Queue connected to the test. + * @access private + */ + function & _createErrorQueue() { + $context = &SimpleTest::getContext(); + $test = &$this->getTestCase(); + $queue = &$context->get('SimpleErrorQueue'); + $queue->setTestCase($test); + return $queue; + } +} + +/** + * Error queue used to record trapped + * errors. + * @package SimpleTest + * @subpackage UnitTester + */ +class SimpleErrorQueue { + var $_queue; + var $_expectation_queue; + var $_test; + var $_using_expect_style = false; + + /** + * Starts with an empty queue. + */ + function SimpleErrorQueue() { + $this->clear(); + } + + /** + * Discards the contents of the error queue. + * @access public + */ + function clear() { + $this->_queue = array(); + $this->_expectation_queue = array(); + } + + /** + * Sets the currently running test case. + * @param SimpleTestCase $test Test case to send messages to. + * @access public + */ + function setTestCase(&$test) { + $this->_test = &$test; + } + + /** + * Sets up an expectation of an error. If this is + * not fulfilled at the end of the test, a failure + * will occour. If the error does happen, then this + * will cancel it out and send a pass message. + * @param SimpleExpectation $expected Expected error match. + * @param string $message Message to display. + * @access public + */ + function expectError($expected, $message) { + $this->_using_expect_style = true; + array_push($this->_expectation_queue, array($expected, $message)); + } + + /** + * Adds an error to the front of the queue. + * @param integer $severity PHP error code. + * @param string $content Text of error. + * @param string $filename File error occoured in. + * @param integer $line Line number of error. + * @access public + */ + function add($severity, $content, $filename, $line) { + $content = str_replace('%', '%%', $content); + if ($this->_using_expect_style) { + $this->_testLatestError($severity, $content, $filename, $line); + } + else { + array_push($this->_queue, array($severity, $content, $filename, $line)); + } + } + + /** + * Any errors still in the queue are sent to the test + * case. Any unfulfilled expectations trigger failures. + * @access public + */ + function tally() { + while (list($severity, $message, $file, $line) = $this->extract()) { + $severity = $this->getSeverityAsString($severity); + $this->_test->error($severity, $message, $file, $line); + } + while (list($expected, $message) = $this->_extractExpectation()) { + $this->_test->assert($expected, false, "%s -> Expected error not caught"); + } + } + + /** + * Tests the error against the most recent expected + * error. + * @param integer $severity PHP error code. + * @param string $content Text of error. + * @param string $filename File error occoured in. + * @param integer $line Line number of error. + * @access private + */ + function _testLatestError($severity, $content, $filename, $line) { + if ($expectation = $this->_extractExpectation()) { + list($expected, $message) = $expectation; + $this->_test->assert($expected, $content, sprintf( + $message, + "%s -> PHP error [$content] severity [". + $this->getSeverityAsString($severity) . + "] in [$filename] line [$line]")); + } + else { + $this->_test->error($severity, $content, $filename, $line); + } + } + + /** + * Pulls the earliest error from the queue. + * @return mixed False if none, or a list of error + * information. Elements are: severity + * as the PHP error code, the error message, + * the file with the error, the line number + * and a list of PHP super global arrays. + * @access public + */ + function extract() { + if (count($this->_queue)) { + return array_shift($this->_queue); + } + return false; + } + + /** + * Pulls the earliest expectation from the queue. + * @return SimpleExpectation False if none. + * @access private + */ + function _extractExpectation() { + if (count($this->_expectation_queue)) { + return array_shift($this->_expectation_queue); + } + return false; + } + + /** + * Converts an error code into it's string + * representation. + * @param $severity PHP integer error code. + * @return String version of error code. + * @access public + * @static + */ + function getSeverityAsString($severity) { + static $map = array( + E_STRICT => 'E_STRICT', + E_ERROR => 'E_ERROR', + E_WARNING => 'E_WARNING', + E_PARSE => 'E_PARSE', + E_NOTICE => 'E_NOTICE', + E_CORE_ERROR => 'E_CORE_ERROR', + E_CORE_WARNING => 'E_CORE_WARNING', + E_COMPILE_ERROR => 'E_COMPILE_ERROR', + E_COMPILE_WARNING => 'E_COMPILE_WARNING', + E_USER_ERROR => 'E_USER_ERROR', + E_USER_WARNING => 'E_USER_WARNING', + E_USER_NOTICE => 'E_USER_NOTICE' + ); + if (version_compare(phpversion(), '5.2.0', '>=')) { + $map[E_RECOVERABLE_ERROR] = 'E_RECOVERABLE_ERROR'; + } + return $map[$severity]; + } +} + +/** + * Error handler that simply stashes any errors into the global + * error queue. Simulates the existing behaviour with respect to + * logging errors, but this feature may be removed in future. + * @param $severity PHP error code. + * @param $message Text of error. + * @param $filename File error occoured in. + * @param $line Line number of error. + * @param $super_globals Hash of PHP super global arrays. + * @static + * @access public + */ +function SimpleTestErrorHandler($severity, $message, $filename = null, $line = null, $super_globals = null, $mask = null) { + $severity = $severity & error_reporting(); + if ($severity) { + restore_error_handler(); + if (ini_get('log_errors')) { + $label = SimpleErrorQueue::getSeverityAsString($severity); + error_log("$label: $message in $filename on line $line"); + } + $context = &SimpleTest::getContext(); + $queue = &$context->get('SimpleErrorQueue'); + $queue->add($severity, $message, $filename, $line); + set_error_handler('SimpleTestErrorHandler'); + } + return true; +} + + diff --git a/modules/simpletest/exceptions.php b/modules/simpletest/exceptions.php new file mode 100644 index 000000000..c79aaf318 --- /dev/null +++ b/modules/simpletest/exceptions.php @@ -0,0 +1,171 @@ +<?php +// $Id$ + +class SimpleExceptionTrappingInvoker extends SimpleInvokerDecorator { + + /** + * Stores the invoker to be wrapped. + * @param SimpleInvoker $invoker Test method runner. + */ + function SimpleExceptionTrappingInvoker($invoker) { + $this->SimpleInvokerDecorator($invoker); + } + + /** + * Invokes a test method whilst trapping expected + * exceptions. Any left over unthrown exceptions + * are then reported as failures. + * @param string $method Test method to call. + */ + function invoke($method) { + $trap = SimpleTest::getContext()->get('SimpleExceptionTrap'); + $trap->clear(); + try { + parent::invoke($method); + } + catch (Exception $exception) { + if (!$trap->isExpected($this->getTestCase(), $exception)) { + $this->getTestCase()->exception($exception); + } + $trap->clear(); + $this->_invoker->getTestCase()->tearDown(); + } + if ($message = $trap->getOutstanding()) { + $this->getTestCase()->fail($message); + } + } +} + +/** + * Tests exceptions either by type or the exact + * exception. This could be improved to accept + * a pattern expectation to test the error + * message, but that will have to come later. + * @package SimpleTest + * @subpackage UnitTester + */ +class ExceptionExpectation extends SimpleExpectation { + private$expected; + + /** + * Sets up the conditions to test against. + * If the expected value is a string, then + * it will act as a test of the class name. + * An exception as the comparison will + * trigger an identical match. Writing this + * down now makes it look doubly dumb. I hope + * come up with a better scheme later. + * @param mixed $expected A class name or an actual + * exception to compare with. + * @param string $message Message to display. + */ + function __construct($expected, $message = '%s') { + $this->expected = $expected; + parent::__construct($message); + } + + /** + * Carry out the test. + * @param Exception $compare Value to check. + * @return boolean True if matched. + */ + function test($compare) { + if (is_string($this->expected)) { + return ($compare instanceof $this->expected); + } + if (get_class($compare) != get_class($this->expected)) { + return false; + } + return $compare->getMessage() == $this->expected->getMessage(); + } + + /** + * Create the message to display describing the test. + * @param Exception $compare Exception to match. + * @return string Final message. + */ + function testMessage($compare) { + if (is_string($this->expected)) { + return "Exception [". $this->describeException($compare) ."] should be type [". $this->expected ."]"; + } + return "Exception [". $this->describeException($compare) ."] should match [". $this->describeException($this->expected) ."]"; + } + + /** + * Summary of an Exception object. + * @param Exception $compare Exception to describe. + * @return string Text description. + */ + protected function describeException($exception) { + return get_class($exception) .": ". $exception->getMessage(); + } +} + +/** + * Stores expected exceptions for when they + * get thrown. Saves the irritating try...catch + * block. + * @package SimpleTest + * @subpackage UnitTester + */ +class SimpleExceptionTrap { + private$expected; + private$message; + + /** + * Clears down the queue ready for action. + */ + function __construct() { + $this->clear(); + } + + /** + * Sets up an expectation of an exception. + * This has the effect of intercepting an + * exception that matches. + * @param SimpleExpectation $expected Expected exception to match. + * @param string $message Message to display. + * @access public + */ + function expectException($expected = false, $message = '%s') { + if ($expected === false) { + $expected = new AnythingExpectation(); + } + if (!SimpleExpectation::isExpectation($expected)) { + $expected = new ExceptionExpectation($expected); + } + $this->expected = $expected; + $this->message = $message; + } + + /** + * Compares the expected exception with any + * in the queue. Issues a pass or fail and + * returns the state of the test. + * @param SimpleTestCase $test Test case to send messages to. + * @param Exception $exception Exception to compare. + * @return boolean False on no match. + */ + function isExpected($test, $exception) { + if ($this->expected) { + return $test->assert($this->expected, $exception, $this->message); + } + return false; + } + + /** + * Tests for any left over exception. + * @return string/false The failure message or false if none. + */ + function getOutstanding() { + return sprintf($this->message, 'Failed to trap exception'); + } + + /** + * Discards the contents of the error queue. + */ + function clear() { + $this->expected = false; + $this->message = false; + } +} diff --git a/modules/simpletest/expectation.php b/modules/simpletest/expectation.php new file mode 100644 index 000000000..1dd85c9ac --- /dev/null +++ b/modules/simpletest/expectation.php @@ -0,0 +1,144 @@ +<?php +// $Id$ + +/** + * Assertion that can display failure information. + * Also includes various helper methods. + * @package SimpleTest + * @subpackage UnitTester + * @abstract + */ +class SimpleExpectation { + var $_dumper = false; + var $_message; + + /** + * Creates a dumper for displaying values and sets + * the test message. + * @param string $message Customised message on failure. + */ + function SimpleExpectation($message = '%s') { + $this->_message = $message; + } + + /** + * Tests the expectation. True if correct. + * @param mixed $compare Comparison value. + * @return boolean True if correct. + * @access public + * @abstract + */ + function test($compare) {} + + /** + * Returns a human readable test message. + * @param mixed $compare Comparison value. + * @return string Description of success + * or failure. + * @access public + * @abstract + */ + function testMessage($compare) {} + + /** + * Overlays the generated message onto the stored user + * message. An additional message can be interjected. + * @param mixed $compare Comparison value. + * @param SimpleDumper $dumper For formatting the results. + * @return string Description of success + * or failure. + * @access public + */ + function overlayMessage($compare, $dumper) { + $this->_dumper = $dumper; + return sprintf($this->_message, $this->testMessage($compare)); + } + + /** + * Accessor for the dumper. + * @return SimpleDumper Current value dumper. + * @access protected + */ + function &_getDumper() { + if (!$this->_dumper) { + $dumper = &new SimpleDumper(); + return $dumper; + } + return $this->_dumper; + } + + /** + * Test to see if a value is an expectation object. + * A useful utility method. + * @param mixed $expectation Hopefully an Epectation + * class. + * @return boolean True if descended from + * this class. + * @access public + * @static + */ + function isExpectation($expectation) { + return is_object($expectation) && is_a($expectation, 'SimpleExpectation'); + } +} + +/** + * A wildcard expectation always matches. + * @package SimpleTest + * @subpackage MockObjects + */ +class AnythingExpectation extends SimpleExpectation { + + /** + * Tests the expectation. Always true. + * @param mixed $compare Ignored. + * @return boolean True. + * @access public + */ + function test($compare) { + return true; + } + + /** + * Returns a human readable test message. + * @param mixed $compare Comparison value. + * @return string Description of success + * or failure. + * @access public + */ + function testMessage($compare) { + $dumper = &$this->_getDumper(); + return 'Anything always matches ['. $dumper->describeValue($compare) .']'; + } +} + +/** + * An expectation that passes on boolean true. + * @package SimpleTest + * @subpackage MockObjects + */ +class TrueExpectation extends SimpleExpectation { + + /** + * Tests the expectation. + * @param mixed $compare Should be true. + * @return boolean True on match. + * @access public + */ + function test($compare) { + return (boolean)$compare; + } + + /** + * Returns a human readable test message. + * @param mixed $compare Comparison value. + * @return string Description of success + * or failure. + * @access public + */ + function testMessage($compare) { + $dumper = &$this->_getDumper(); + return 'Expected true, got ['. $dumper->describeValue($compare) .']'; + } +} + diff --git a/modules/simpletest/invoker.php b/modules/simpletest/invoker.php new file mode 100644 index 000000000..7944c1b4f --- /dev/null +++ b/modules/simpletest/invoker.php @@ -0,0 +1,119 @@ +<?php +// $Id$ + +/** + * This is called by the class runner to run a + * single test method. Will also run the setUp() + * and tearDown() methods. + * @package SimpleTest + * @subpackage UnitTester + */ +class SimpleInvoker { + var $_test_case; + + /** + * Stashes the test case for later. + * @param SimpleTestCase $test_case Test case to run. + */ + function SimpleInvoker(&$test_case) { + $this->_test_case = &$test_case; + } + + /** + * Accessor for test case being run. + * @return SimpleTestCase Test case. + * @access public + */ + function &getTestCase() { + return $this->_test_case; + } + + /** + * Runs test level set up. Used for changing + * the mechanics of base test cases. + * @param string $method Test method to call. + * @access public + */ + function before($method) { + $this->_test_case->before($method); + } + + /** + * Invokes a test method and buffered with setUp() + * and tearDown() calls. + * @param string $method Test method to call. + * @access public + */ + function invoke($method) { + $this->_test_case->setUp(); + $this->_test_case->$method(); + $this->_test_case->tearDown(); + } + + /** + * Runs test level clean up. Used for changing + * the mechanics of base test cases. + * @param string $method Test method to call. + * @access public + */ + function after($method) { + $this->_test_case->after($method); + } +} + +/** + * Do nothing decorator. Just passes the invocation + * straight through. + * @package SimpleTest + * @subpackage UnitTester + */ +class SimpleInvokerDecorator { + var $_invoker; + + /** + * Stores the invoker to wrap. + * @param SimpleInvoker $invoker Test method runner. + */ + function SimpleInvokerDecorator(&$invoker) { + $this->_invoker = &$invoker; + } + + /** + * Accessor for test case being run. + * @return SimpleTestCase Test case. + * @access public + */ + function &getTestCase() { + return $this->_invoker->getTestCase(); + } + + /** + * Runs test level set up. Used for changing + * the mechanics of base test cases. + * @param string $method Test method to call. + * @access public + */ + function before($method) { + $this->_invoker->before($method); + } + + /** + * Invokes a test method and buffered with setUp() + * and tearDown() calls. + * @param string $method Test method to call. + * @access public + */ + function invoke($method) { + $this->_invoker->invoke($method); + } + + /** + * Runs test level clean up. Used for changing + * the mechanics of base test cases. + * @param string $method Test method to call. + * @access public + */ + function after($method) { + $this->_invoker->after($method); + } +} diff --git a/modules/simpletest/reporter.php b/modules/simpletest/reporter.php new file mode 100644 index 000000000..c6bca2fd7 --- /dev/null +++ b/modules/simpletest/reporter.php @@ -0,0 +1,283 @@ +<?php +// $Id$ + +/** + * Sample minimal test displayer. Generates only + * failure messages and a pass count. + * @package SimpleTest + * @subpackage UnitTester + */ +class HtmlReporter extends SimpleReporter { + var $_character_set; + + /** + * Does nothing yet. The first output will + * be sent on the first test start. For use + * by a web browser. + * @access public + */ + function HtmlReporter($character_set = 'ISO-8859-1') { + $this->SimpleReporter(); + $this->_character_set = $character_set; + } + + /** + * Paints the top of the web page setting the + * title to the name of the starting test. + * @param string $test_name Name class of test. + * @access public + */ + function paintHeader($test_name) { + $this->sendNoCacheHeaders(); + print "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\" \"http://www.w3.org/TR/html4/loose.dtd\">"; + print "<html>\n<head>\n<title>$test_name</title>\n"; + print "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=". $this->_character_set ."\">\n"; + print "<style type=\"text/css\">\n"; + print $this->_getCss() ."\n"; + print "</style>\n"; + print "</head>\n<body>\n"; + print "<h1>$test_name</h1>\n"; + flush(); + } + + /** + * Send the headers necessary to ensure the page is + * reloaded on every request. Otherwise you could be + * scratching your head over out of date test data. + * @access public + * @static + */ + function sendNoCacheHeaders() { + if (!headers_sent()) { + header("Expires: Mon, 26 Jul 1997 05:00:00 GMT"); + header("Last-Modified: ". gmdate("D, d M Y H:i:s") ." GMT"); + header("Cache-Control: no-store, no-cache, must-revalidate"); + header("Cache-Control: post-check=0, pre-check=0", false); + header("Pragma: no-cache"); + } + } + + /** + * Paints the CSS. Add additional styles here. + * @return string CSS code as text. + * @access protected + */ + function _getCss() { + return ".fail { background-color: inherit; color: red; }".".pass { background-color: inherit; color: green; }"." pre { background-color: lightgray; color: inherit; }"; + } + + /** + * Paints the end of the test with a summary of + * the passes and failures. + * @param string $test_name Name class of test. + * @access public + */ + function paintFooter($test_name) { + $colour = ($this->getFailCount() + $this->getExceptionCount() > 0 ? "red" : "green"); + print "<div style=\""; + print "padding: 8px; margin-top: 1em; background-color: $colour; color: white;"; + print "\">"; + print $this->getTestCaseProgress() ."/". $this->getTestCaseCount(); + print " test cases complete:\n"; + print "<strong>". $this->getPassCount() ."</strong> passes, "; + print "<strong>". $this->getFailCount() ."</strong> fails and "; + print "<strong>". $this->getExceptionCount() ."</strong> exceptions."; + print "</div>\n"; + print "</body>\n</html>\n"; + } + + /** + * Paints the test failure with a breadcrumbs + * trail of the nesting test suites below the + * top level test. + * @param string $message Failure message displayed in + * the context of the other tests. + * @access public + */ + function paintFail($message) { + parent::paintFail($message); + print "<span class=\"fail\">Fail</span>: "; + $breadcrumb = $this->getTestList(); + array_shift($breadcrumb); + print implode(" -> ", $breadcrumb); + print " -> ". $this->_htmlEntities($message) ."<br />\n"; + } + + /** + * Paints a PHP error. + * @param string $message Message is ignored. + * @access public + */ + function paintError($message) { + parent::paintError($message); + print "<span class=\"fail\">Exception</span>: "; + $breadcrumb = $this->getTestList(); + array_shift($breadcrumb); + print implode(" -> ", $breadcrumb); + print " -> <strong>". $this->_htmlEntities($message) ."</strong><br />\n"; + } + + /** + * Paints a PHP exception. + * @param Exception $exception Exception to display. + * @access public + */ + function paintException($exception) { + parent::paintException($exception); + print "<span class=\"fail\">Exception</span>: "; + $breadcrumb = $this->getTestList(); + array_shift($breadcrumb); + print implode(" -> ", $breadcrumb); + $message = 'Unexpected exception of type ['. get_class($exception) .'] with message ['. $exception->getMessage() .'] in ['. $exception->getFile() .' line '. $exception->getLine() .']'; + print " -> <strong>". $this->_htmlEntities($message) ."</strong><br />\n"; + } + + /** + * Prints the message for skipping tests. + * @param string $message Text of skip condition. + * @access public + */ + function paintSkip($message) { + parent::paintSkip($message); + print "<span class=\"pass\">Skipped</span>: "; + $breadcrumb = $this->getTestList(); + array_shift($breadcrumb); + print implode(" -> ", $breadcrumb); + print " -> ". $this->_htmlEntities($message) ."<br />\n"; + } + + /** + * Paints formatted text such as dumped variables. + * @param string $message Text to show. + * @access public + */ + function paintFormattedMessage($message) { + print '<pre>'. $this->_htmlEntities($message) .'</pre>'; + } + + /** + * Character set adjusted entity conversion. + * @param string $message Plain text or Unicode message. + * @return string Browser readable message. + * @access protected + */ + function _htmlEntities($message) { + return htmlentities($message, ENT_COMPAT, $this->_character_set); + } +} + +/** + * Sample minimal test displayer. Generates only + * failure messages and a pass count. For command + * line use. I've tried to make it look like JUnit, + * but I wanted to output the errors as they arrived + * which meant dropping the dots. + * @package SimpleTest + * @subpackage UnitTester + */ +class TextReporter extends SimpleReporter { + + /** + * Does nothing yet. The first output will + * be sent on the first test start. + * @access public + */ + function TextReporter() { + $this->SimpleReporter(); + } + + /** + * Paints the title only. + * @param string $test_name Name class of test. + * @access public + */ + function paintHeader($test_name) { + if (!SimpleReporter::inCli()) { + header('Content-type: text/plain'); + } + print "$test_name\n"; + flush(); + } + + /** + * Paints the end of the test with a summary of + * the passes and failures. + * @param string $test_name Name class of test. + * @access public + */ + function paintFooter($test_name) { + if ($this->getFailCount() + $this->getExceptionCount() == 0) { + print "OK\n"; + } + else { + print "FAILURES!!!\n"; + } + print "Test cases run: ". $this->getTestCaseProgress() ."/". $this->getTestCaseCount() .", Passes: ". $this->getPassCount() .", Failures: ". $this->getFailCount() .", Exceptions: ". $this->getExceptionCount() ."\n"; + } + + /** + * Paints the test failure as a stack trace. + * @param string $message Failure message displayed in + * the context of the other tests. + * @access public + */ + function paintFail($message) { + parent::paintFail($message); + print $this->getFailCount() .") $message\n"; + $breadcrumb = $this->getTestList(); + array_shift($breadcrumb); + print "\tin ". implode("\n\tin ", array_reverse($breadcrumb)); + print "\n"; + } + + /** + * Paints a PHP error or exception. + * @param string $message Message to be shown. + * @access public + * @abstract + */ + function paintError($message) { + parent::paintError($message); + print "Exception ". $this->getExceptionCount() ."!\n$message\n"; + $breadcrumb = $this->getTestList(); + array_shift($breadcrumb); + print "\tin ". implode("\n\tin ", array_reverse($breadcrumb)); + print "\n"; + } + + /** + * Paints a PHP error or exception. + * @param Exception $exception Exception to describe. + * @access public + * @abstract + */ + function paintException($exception) { + parent::paintException($exception); + $message = 'Unexpected exception of type ['. get_class($exception) .'] with message ['. $exception->getMessage() .'] in ['. $exception->getFile() .' line '. $exception->getLine() .']'; + print "Exception ". $this->getExceptionCount() ."!\n$message\n"; + $breadcrumb = $this->getTestList(); + array_shift($breadcrumb); + print "\tin ". implode("\n\tin ", array_reverse($breadcrumb)); + print "\n"; + } + + /** + * Prints the message for skipping tests. + * @param string $message Text of skip condition. + * @access public + */ + function paintSkip($message) { + parent::paintSkip($message); + print "Skip: $message\n"; + } + + /** + * Paints formatted text such as dumped variables. + * @param string $message Text to show. + * @access public + */ + function paintFormattedMessage($message) { + print "$message\n"; + flush(); + } +} diff --git a/modules/simpletest/scorer.php b/modules/simpletest/scorer.php new file mode 100644 index 000000000..2215d1dff --- /dev/null +++ b/modules/simpletest/scorer.php @@ -0,0 +1,413 @@ +<?php +// $Id$ + +/** + * Can recieve test events and display them. Display + * is achieved by making display methods available + * and visiting the incoming event. + * @package SimpleTest + * @subpackage UnitTester + * @abstract + */ +class SimpleScorer { + var$_passes; + var$_fails; + var$_exceptions; + var$_is_dry_run; + + /** + * Starts the test run with no results. + * @access public + */ + function SimpleScorer() { + $this->_passes = 0; + $this->_fails = 0; + $this->_exceptions = 0; + $this->_is_dry_run = false; + } + + /** + * Signals that the next evaluation will be a dry + * run. That is, the structure events will be + * recorded, but no tests will be run. + * @param boolean $is_dry Dry run if true. + * @access public + */ + function makeDry($is_dry = true) { + $this->_is_dry_run = $is_dry; + } + + /** + * The reporter has a veto on what should be run. + * @param string $test_case_name name of test case. + * @param string $method Name of test method. + * @access public + */ + function shouldInvoke($test_case_name, $method) { + return !$this->_is_dry_run; + } + + /** + * Can wrap the invoker in preperation for running + * a test. + * @param SimpleInvoker $invoker Individual test runner. + * @return SimpleInvoker Wrapped test runner. + * @access public + */ + function & createInvoker(&$invoker) { + return $invoker; + } + + /** + * Accessor for current status. Will be false + * if there have been any failures or exceptions. + * Used for command line tools. + * @return boolean True if no failures. + * @access public + */ + function getStatus() { + if ($this->_exceptions + $this->_fails > 0) { + return false; + } + return true; + } + + /** + * Paints the start of a group test. + * @param string $test_name Name of test or other label. + * @param integer $size Number of test cases starting. + * @access public + */ + function paintGroupStart($test_name, $size) {} + + /** + * Paints the end of a group test. + * @param string $test_name Name of test or other label. + * @access public + */ + function paintGroupEnd($test_name) {} + + /** + * Paints the start of a test case. + * @param string $test_name Name of test or other label. + * @access public + */ + function paintCaseStart($test_name) {} + + /** + * Paints the end of a test case. + * @param string $test_name Name of test or other label. + * @access public + */ + function paintCaseEnd($test_name) {} + + /** + * Paints the start of a test method. + * @param string $test_name Name of test or other label. + * @access public + */ + function paintMethodStart($test_name) {} + + /** + * Paints the end of a test method. + * @param string $test_name Name of test or other label. + * @access public + */ + function paintMethodEnd($test_name) {} + + /** + * Increments the pass count. + * @param string $message Message is ignored. + * @access public + */ + function paintPass($message) { + $this->_passes++; + } + + /** + * Increments the fail count. + * @param string $message Message is ignored. + * @access public + */ + function paintFail($message) { + $this->_fails++; + } + + /** + * Deals with PHP 4 throwing an error. + * @param string $message Text of error formatted by + * the test case. + * @access public + */ + function paintError($message) { + $this->_exceptions++; + } + + /** + * Deals with PHP 5 throwing an exception. + * @param Exception $exception The actual exception thrown. + * @access public + */ + function paintException($exception) { + $this->_exceptions++; + } + + /** + * Prints the message for skipping tests. + * @param string $message Text of skip condition. + * @access public + */ + function paintSkip($message) {} + + /** + * Accessor for the number of passes so far. + * @return integer Number of passes. + * @access public + */ + function getPassCount() { + return $this->_passes; + } + + /** + * Accessor for the number of fails so far. + * @return integer Number of fails. + * @access public + */ + function getFailCount() { + return $this->_fails; + } + + /** + * Accessor for the number of untrapped errors + * so far. + * @return integer Number of exceptions. + * @access public + */ + function getExceptionCount() { + return $this->_exceptions; + } + + /** + * Paints a simple supplementary message. + * @param string $message Text to display. + * @access public + */ + function paintMessage($message) {} + + /** + * Paints a formatted ASCII message such as a + * variable dump. + * @param string $message Text to display. + * @access public + */ + function paintFormattedMessage($message) {} + + /** + * By default just ignores user generated events. + * @param string $type Event type as text. + * @param mixed $payload Message or object. + * @access public + */ + function paintSignal($type, $payload) {} +} + +/** + * Recipient of generated test messages that can display + * page footers and headers. Also keeps track of the + * test nesting. This is the main base class on which + * to build the finished test (page based) displays. + * @package SimpleTest + * @subpackage UnitTester + */ +class SimpleReporter extends SimpleScorer { + var$_test_stack; + var$_size; + var$_progress; + + /** + * Starts the display with no results in. + * @access public + */ + function SimpleReporter() { + $this->SimpleScorer(); + $this->_test_stack = array(); + $this->_size = null; + $this->_progress = 0; + } + + /** + * Gets the formatter for variables and other small + * generic data items. + * @return SimpleDumper Formatter. + * @access public + */ + function getDumper() { + return new SimpleDumper(); + } + + /** + * Paints the start of a group test. Will also paint + * the page header and footer if this is the + * first test. Will stash the size if the first + * start. + * @param string $test_name Name of test that is starting. + * @param integer $size Number of test cases starting. + * @access public + */ + function paintGroupStart($test_name, $size) { + if (!isset($this->_size)) { + $this->_size = $size; + } + if (count($this->_test_stack) == 0) { + $this->paintHeader($test_name); + } + $this->_test_stack[] = $test_name; + } + + /** + * Paints the end of a group test. Will paint the page + * footer if the stack of tests has unwound. + * @param string $test_name Name of test that is ending. + * @param integer $progress Number of test cases ending. + * @access public + */ + function paintGroupEnd($test_name) { + array_pop($this->_test_stack); + if (count($this->_test_stack) == 0) { + $this->paintFooter($test_name); + } + } + + /** + * Paints the start of a test case. Will also paint + * the page header and footer if this is the + * first test. Will stash the size if the first + * start. + * @param string $test_name Name of test that is starting. + * @access public + */ + function paintCaseStart($test_name) { + if (!isset($this->_size)) { + $this->_size = 1; + } + if (count($this->_test_stack) == 0) { + $this->paintHeader($test_name); + } + $this->_test_stack[] = $test_name; + } + + /** + * Paints the end of a test case. Will paint the page + * footer if the stack of tests has unwound. + * @param string $test_name Name of test that is ending. + * @access public + */ + function paintCaseEnd($test_name) { + $this->_progress++; + array_pop($this->_test_stack); + if (count($this->_test_stack) == 0) { + $this->paintFooter($test_name); + } + } + + /** + * Paints the start of a test method. + * @param string $test_name Name of test that is starting. + * @access public + */ + function paintMethodStart($test_name) { + $this->_test_stack[] = $test_name; + } + + /** + * Paints the end of a test method. Will paint the page + * footer if the stack of tests has unwound. + * @param string $test_name Name of test that is ending. + * @access public + */ + function paintMethodEnd($test_name) { + array_pop($this->_test_stack); + } + + /** + * Paints the test document header. + * @param string $test_name First test top level + * to start. + * @access public + * @abstract + */ + function paintHeader($test_name) {} + + /** + * Paints the test document footer. + * @param string $test_name The top level test. + * @access public + * @abstract + */ + function paintFooter($test_name) {} + + /** + * Accessor for internal test stack. For + * subclasses that need to see the whole test + * history for display purposes. + * @return array List of methods in nesting order. + * @access public + */ + function getTestList() { + return $this->_test_stack; + } + + /** + * Accessor for total test size in number + * of test cases. Null until the first + * test is started. + * @return integer Total number of cases at start. + * @access public + */ + function getTestCaseCount() { + return $this->_size; + } + + /** + * Accessor for the number of test cases + * completed so far. + * @return integer Number of ended cases. + * @access public + */ + function getTestCaseProgress() { + return $this->_progress; + } + + /** + * Static check for running in the comand line. + * @return boolean True if CLI. + * @access public + * @static + */ + function inCli() { + return php_sapi_name() == 'cli'; + } +} + +/** + * For modifying the behaviour of the visual reporters. + * @package SimpleTest + * @subpackage UnitTester + */ +class SimpleReporterDecorator { + var $_reporter; + + /** + * Mediates between the reporter and the test case. + * @param SimpleScorer $reporter Reporter to receive events. + */ + function __construct(&$reporter) { + $this->_reporter = &$reporter; + } + + function __call($method, $arguments) { + if (method_exists($this->_reporter, $method)) { + return call_user_func_array($this->_reporter->$method, $arguments); + } + } +} diff --git a/modules/simpletest/test_case.php b/modules/simpletest/test_case.php new file mode 100644 index 000000000..b7b724061 --- /dev/null +++ b/modules/simpletest/test_case.php @@ -0,0 +1,615 @@ +<?php +// $Id$ + +/** + * Basic test case. This is the smallest unit of a test + * suite. It searches for + * all methods that start with the the string "test" and + * runs them. Working test cases extend this class. + */ +class SimpleTestCase { + var $_label = false; + var $_reporter; + var $_observers; + var $_should_skip = false; + + /** + * Sets up the test with no display. + * @param string $label If no test name is given then + * the class name is used. + * @access public + */ + function SimpleTestCase($label = false) { + if ($label) { + $this->_label = $label; + } + } + + /** + * Accessor for the test name for subclasses. + * @return string Name of the test. + * @access public + */ + function getLabel() { + return $this->_label ? $this->_label : get_class($this); + } + + /** + * This is a placeholder for skipping tests. In this + * method you place skipIf() and skipUnless() calls to + * set the skipping state. + * @access public + */ + function skip() {} + + /** + * Will issue a message to the reporter and tell the test + * case to skip if the incoming flag is true. + * @param string $should_skip Condition causing the tests to be skipped. + * @param string $message Text of skip condition. + * @access public + */ + function skipIf($should_skip, $message = '%s') { + if ($should_skip && !$this->_should_skip) { + $this->_should_skip = true; + $message = sprintf($message, 'Skipping ['. get_class($this) .']'); + $this->_reporter->paintSkip($message . $this->getAssertionLine()); + } + } + + /** + * Will issue a message to the reporter and tell the test + * case to skip if the incoming flag is false. + * @param string $shouldnt_skip Condition causing the tests to be run. + * @param string $message Text of skip condition. + * @access public + */ + function skipUnless($shouldnt_skip, $message = false) { + $this->skipIf(!$shouldnt_skip, $message); + } + + /** + * Used to invoke the single tests. + * @return SimpleInvoker Individual test runner. + * @access public + */ + function & createInvoker() { + $invoker = &new SimpleErrorTrappingInvoker(new SimpleInvoker($this)); + if (version_compare(phpversion(), '5') >= 0) { + $invoker = &new SimpleExceptionTrappingInvoker($invoker); + } + return $invoker; + } + + /** + * Uses reflection to run every method within itself + * starting with the string "test" unless a method + * is specified. + * @param SimpleReporter $reporter Current test reporter. + * @return boolean True if all tests passed. + * @access public + */ + function run(&$reporter) { + $context = &SimpleTest::getContext(); + $context->setTest($this); + $context->setReporter($reporter); + $this->_reporter = &$reporter; + $started = false; + foreach ($this->getTests() as $method) { + if ($reporter->shouldInvoke($this->getLabel(), $method)) { + $this->skip(); + if ($this->_should_skip) { + break; + } + if (!$started) { + $reporter->paintCaseStart($this->getLabel()); + $started = true; + } + $invoker = &$this->_reporter->createInvoker($this->createInvoker()); + $invoker->before($method); + $invoker->invoke($method); + $invoker->after($method); + } + } + if ($started) { + $reporter->paintCaseEnd($this->getLabel()); + } + unset($this->_reporter); + return $reporter->getStatus(); + } + + /** + * Gets a list of test names. Normally that will + * be all internal methods that start with the + * name "test". This method should be overridden + * if you want a different rule. + * @return array List of test names. + * @access public + */ + function getTests() { + $methods = array(); + foreach (get_class_methods(get_class($this)) as $method) { + if ($this->_isTest($method)) { + $methods[] = $method; + } + } + return $methods; + } + + /** + * Tests to see if the method is a test that should + * be run. Currently any method that starts with 'test' + * is a candidate unless it is the constructor. + * @param string $method Method name to try. + * @return boolean True if test method. + * @access protected + */ + function _isTest($method) { + if (strtolower(substr($method, 0, 4)) == 'test') { + return !is_a($this, strtolower($method)); + } + return false; + } + + /** + * Announces the start of the test. + * @param string $method Test method just started. + * @access public + */ + function before($method) { + $this->_reporter->paintMethodStart($method); + $this->_observers = array(); + } + + /** + * Sets up unit test wide variables at the start + * of each test method. To be overridden in + * actual user test cases. + * @access public + */ + function setUp() {} + + /** + * Clears the data set in the setUp() method call. + * To be overridden by the user in actual user test cases. + * @access public + */ + function tearDown() {} + + /** + * Announces the end of the test. Includes private clean up. + * @param string $method Test method just finished. + * @access public + */ + function after($method) { + for ($i = 0; $i < count($this->_observers); $i++) { + $this->_observers[$i]->atTestEnd($method, $this); + } + $this->_reporter->paintMethodEnd($method); + } + + /** + * Sets up an observer for the test end. + * @param object $observer Must have atTestEnd() + * method. + * @access public + */ + function tell(&$observer) { + $this->_observers[] = &$observer; + } + + /** + * @deprecated + */ + function pass($message = 'Pass', $group = 'Other') { + if (!isset($this->_reporter)) { + trigger_error('Can only make assertions within test methods'); + } + $this->_reporter->paintPass($message . $this->getAssertionLine(), $group); + return TRUE; + } + + /** + * Sends a fail event with a message. + * @param string $message Message to send. + * @access public + */ + function fail($message = 'Fail', $group = 'Other') { + if (!isset($this->_reporter)) { + trigger_error('Can only make assertions within test methods'); + } + $this->_reporter->paintFail($message . $this->getAssertionLine(), $group); + return FALSE; + } + + /** + * Formats a PHP error and dispatches it to the + * reporter. + * @param integer $severity PHP error code. + * @param string $message Text of error. + * @param string $file File error occoured in. + * @param integer $line Line number of error. + * @access public + */ + function error($severity, $message, $file, $line) { + if (!isset($this->_reporter)) { + trigger_error('Can only make assertions within test methods'); + } + $this->_reporter->paintError("Unexpected PHP error [$message] severity [$severity] in [$file line $line]"); + } + + /** + * Formats an exception and dispatches it to the + * reporter. + * @param Exception $exception Object thrown. + * @access public + */ + function exception($exception) { + $this->_reporter->paintException($exception); + } + + /** + * @deprecated + */ + function signal($type, &$payload) { + if (!isset($this->_reporter)) { + trigger_error('Can only make assertions within test methods'); + } + $this->_reporter->paintSignal($type, $payload); + } + + /** + * Runs an expectation directly, for extending the + * tests with new expectation classes. + * @param SimpleExpectation $expectation Expectation subclass. + * @param mixed $compare Value to compare. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assert(&$expectation, $compare, $message = '%s', $group = 'Other') { + if ($expectation->test($compare)) { + return $this->pass(sprintf($message, $expectation->overlayMessage($compare, $this->_reporter->getDumper())), $group); + } + else { + return $this->fail(sprintf($message, $expectation->overlayMessage($compare, $this->_reporter->getDumper())), $group); + } + } + + /** + * @deprecated + */ + function assertExpectation(&$expectation, $compare, $message = '%s', $group = 'Other') { + return $this->assert($expectation, $compare, $message, $group); + } + + /** + * Uses a stack trace to find the line of an assertion. + * @return string Line number of first assert* + * method embedded in format string. + * @access public + */ + function getAssertionLine() { + $trace = new SimpleStackTrace(array('assert', 'expect', 'pass', 'fail', 'skip')); + return $trace->traceMethod(); + } + + /** + * @deprecated + */ + function sendMessage($message) { + $this->_reporter->PaintMessage($message); + } + + /** + * Accessor for the number of subtests. + * @return integer Number of test cases. + * @access public + * @static + */ + function getSize() { + return 1; + } +} + +/** + * Helps to extract test cases automatically from a file. + */ +class SimpleFileLoader { + + /** + * Builds a test suite from a library of test cases. + * The new suite is composed into this one. + * @param string $test_file File name of library with + * test case classes. + * @return TestSuite The new test suite. + * @access public + */ + function &load($test_file) { + $existing_classes = get_declared_classes(); + include_once ($test_file); + $classes = $this->selectRunnableTests( + array_diff(get_declared_classes(), $existing_classes)); + $suite = &$this->createSuiteFromClasses($test_file, $classes); + return $suite; + } + + /** + * Calculates the incoming test cases. Skips abstract + * and ignored classes. + * @param array $candidates Candidate classes. + * @return array New classes which are test + * cases that shouldn't be ignored. + * @access public + */ + function selectRunnableTests($candidates) { + $classes = array(); + foreach ($candidates as $class) { + if (TestSuite::getBaseTestCase($class)) { + $reflection = new ReflectionClass($class); + if ($reflection->isAbstract()) { + SimpleTest::ignore($class); + } + $classes[] = $class; + } + } + return $classes; + } + + /** + * Builds a test suite from a class list. + * @param string $title Title of new group. + * @param array $classes Test classes. + * @return TestSuite Group loaded with the new + * test cases. + * @access public + */ + function &createSuiteFromClasses($title, $classes) { + if (count($classes) == 0) { + $suite = &new BadTestSuite($title, "No runnable test cases in [$title]"); + return $suite; + } + SimpleTest::ignoreParentsIfIgnored($classes); + $suite = &new TestSuite($title); + foreach ($classes as $class) { + if (!SimpleTest::isIgnored($class)) { + $suite->addTestClass($class); + } + } + return $suite; + } +} + +/** + * This is a composite test class for combining + * test cases and other RunnableTest classes into + * a group test. + * @package SimpleTest + * @subpackage UnitTester + */ +class TestSuite { + var $_label; + var $_test_cases; + + /** + * Sets the name of the test suite. + * @param string $label Name sent at the start and end + * of the test. + * @access public + */ + function TestSuite($label = false) { + $this->_label = $label; + $this->_test_cases = array(); + } + + /** + * Accessor for the test name for subclasses. If the suite + * wraps a single test case the label defaults to the name of that test. + * @return string Name of the test. + * @access public + */ + function getLabel() { + if (!$this->_label) { + return ($this->getSize() == 1) ? get_class($this->_test_cases[0]) : get_class($this); + } + else { + return $this->_label; + } + } + + /** + * @deprecated + */ + function addTestCase(&$test_case) { + $this->_test_cases[] = &$test_case; + } + + /** + * @deprecated + */ + function addTestClass($class) { + if (TestSuite::getBaseTestCase($class) == 'testsuite') { + $this->_test_cases[] = &new $class(); + } + else { + $this->_test_cases[] = $class; + } + } + + /** + * Adds a test into the suite by instance or class. The class will + * be instantiated if it's a test suite. + * @param SimpleTestCase $test_case Suite or individual test + * case implementing the + * runnable test interface. + * @access public + */ + function add(&$test_case) { + if (!is_string($test_case)) { + $this->_test_cases[] = &$test_case; + } + elseif (TestSuite::getBaseTestCase($class) == 'testsuite') { + $this->_test_cases[] = &new $class(); + } + else { + $this->_test_cases[] = $class; + } + } + + /** + * @deprecated + */ + function addTestFile($test_file) { + $this->addFile($test_file); + } + + /** + * Builds a test suite from a library of test cases. + * The new suite is composed into this one. + * @param string $test_file File name of library with + * test case classes. + * @access public + */ + function addFile($test_file) { + $extractor = new SimpleFileLoader(); + $this->add($extractor->load($test_file)); + } + + /** + * Delegates to a visiting collector to add test + * files. + * @param string $path Path to scan from. + * @param SimpleCollector $collector Directory scanner. + * @access public + */ + function collect($path, &$collector) { + $collector->collect($this, $path); + } + + /** + * Invokes run() on all of the held test cases, instantiating + * them if necessary. + * @param SimpleReporter $reporter Current test reporter. + * @access public + */ + function run(&$reporter) { + $reporter->paintGroupStart($this->getLabel(), $this->getSize()); + for ($i = 0, $count = count($this->_test_cases); $i < $count; $i++) { + if (is_string($this->_test_cases[$i])) { + $class = $this->_test_cases[$i]; + $test = &new $class(); + $test->run($reporter); + unset($test); + } + else { + $this->_test_cases[$i]->run($reporter); + } + } + $reporter->paintGroupEnd($this->getLabel()); + return $reporter->getStatus(); + } + + /** + * Number of contained test cases. + * @return integer Total count of cases in the group. + * @access public + */ + function getSize() { + $count = 0; + foreach ($this->_test_cases as $case) { + if (is_string($case)) { + $count++; + } + else { + $count += $case->getSize(); + } + } + return $count; + } + + /** + * Test to see if a class is derived from the + * SimpleTestCase class. + * @param string $class Class name. + * @access public + * @static + */ + function getBaseTestCase($class) { + while ($class = get_parent_class($class)) { + $class = strtolower($class); + if ($class == 'simpletestcase' || $class == 'testsuite') { + return $class; + } + } + return false; + } +} + +/** + * @package SimpleTest + * @subpackage UnitTester + * @deprecated + */ +class GroupTest extends TestSuite {} + +/** + * This is a failing group test for when a test suite hasn't + * loaded properly. + * @package SimpleTest + * @subpackage UnitTester + */ +class BadTestSuite { + var $_label; + var $_error; + + /** + * Sets the name of the test suite and error message. + * @param string $label Name sent at the start and end + * of the test. + * @access public + */ + function BadTestSuite($label, $error) { + $this->_label = $label; + $this->_error = $error; + } + + /** + * Accessor for the test name for subclasses. + * @return string Name of the test. + * @access public + */ + function getLabel() { + return $this->_label; + } + + /** + * Sends a single error to the reporter. + * @param SimpleReporter $reporter Current test reporter. + * @access public + */ + function run(&$reporter) { + $reporter->paintGroupStart($this->getLabel(), $this->getSize()); + $reporter->paintFail('Bad TestSuite ['. $this->getLabel() . + '] with error ['. $this->_error .']'); + $reporter->paintGroupEnd($this->getLabel()); + return $reporter->getStatus(); + } + + /** + * Number of contained test cases. Always zero. + * @return integer Total count of cases in the group. + * @access public + */ + function getSize() { + return 0; + } +} + +/** + * @package SimpleTest + * @subpackage UnitTester + * @deprecated + */ +class BadGroupTest extends BadTestSuite {} + + diff --git a/modules/simpletest/unit_tester.php b/modules/simpletest/unit_tester.php new file mode 100644 index 000000000..cf4ea16ba --- /dev/null +++ b/modules/simpletest/unit_tester.php @@ -0,0 +1,178 @@ +<?php +// $Id$ + +/** + * Standard unit test class for day to day testing + * of PHP code XP style. Adds some useful standard + * assertions. + */ +class UnitTestCase extends SimpleTestCase { + + /** + * Creates an empty test case. Should be subclassed + * with test methods for a functional test case. + * @param string $label Name of test case. Will use + * the class name if none specified. + * @access public + */ + function UnitTestCase($label = false) { + if (!$label) { + $label = get_class($this); + } + $this->SimpleTestCase($label); + } + + /** + * Called from within the test methods to register + * passes and failures. + * @param boolean $result Pass on true. + * @param string $message Message to display describing + * the test state. + * @return boolean True on pass + * @access public + */ + function assertTrue($result, $message = FALSE, $group = 'Other') { + return $this->assert(new TrueExpectation(), $result, $message, $group); + } + + /** + * Will be true on false and vice versa. False + * is the PHP definition of false, so that null, + * empty strings, zero and an empty array all count + * as false. + * @param boolean $result Pass on false. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertFalse($result, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $message = sprintf($message, 'Expected false, got ['. $dumper->describeValue($result) .']'); + return $this->assertTrue(!$result, $message, $group); + } + + /** + * Will be true if the value is null. + * @param null $value Supposedly null value. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertNull($value, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $message = sprintf($message, '['. $dumper->describeValue($value) .'] should be null'); + return $this->assertTrue(!isset($value), $message, $group); + } + + /** + * Will be true if the value is set. + * @param mixed $value Supposedly set value. + * @param string $message Message to display. + * @return boolean True on pass. + * @access public + */ + function assertNotNull($value, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $message = sprintf($message, '['. $dumper->describeValue($value) .'] should not be null'); + return $this->assertTrue(isset($value), $message, $group); + } + + /** + * Will trigger a pass if the two parameters have + * the same value only. Otherwise a fail. + * @param mixed $first Value to compare. + * @param mixed $second Value to compare. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertEqual($first, $second, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $message = sprintf($message, 'Expected '. $dumper->describeValue($first) .', got ['. $dumper->describeValue($second) .']'); + $this->assertTrue($first == $second, $message, $group); + } + + /** + * Will trigger a pass if the two parameters have + * a different value. Otherwise a fail. + * @param mixed $first Value to compare. + * @param mixed $second Value to compare. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertNotEqual($first, $second, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $message = sprintf($message, 'Expected '. $dumper->describeValue($first) .', not equal to '. $dumper->describeValue($second)); + $this->assertTrue($first != $second, $message, $group); + } + + /** + * Will trigger a pass if the two parameters have + * the same value and same type. Otherwise a fail. + * @param mixed $first Value to compare. + * @param mixed $second Value to compare. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertIdentical($first, $second, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $message = sprintf($message, 'Expected '. $dumper->describeValue($first) .', got ['. $dumper->describeValue($second) .']'); + $this->assertTrue($first === $second, $message, $group); + } + + /** + * Will trigger a pass if the two parameters have + * the different value or different type. + * @param mixed $first Value to compare. + * @param mixed $second Value to compare. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertNotIdentical($first, $second, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $message = sprintf($message, 'Expected '. $dumper->describeValue($first) .', not identical to '. $dumper->describeValue($second)); + $this->assertTrue($first !== $second, $message, $group); + } + + /** + * Will trigger a pass if the Perl regex pattern + * is found in the subject. Fail otherwise. + * @param string $pattern Perl regex to look for including + * the regex delimiters. + * @param string $subject String to search in. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertPattern($pattern, $subject, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $replace = 'Pattern '. $pattern .' detected in ['. $dumper->describeValue($subject) .']'; + $found = preg_match($pattern, $subject, $matches); + if ($found) { + $position = strpos($subject, $matches[0]); + $replace .= ' in region ['. $dumper->clipString($subject, 100, $position) .']'; + } + $message = sprintf($message, $replace); + $this->assertTrue($found, $message, $group); + } + + /** + * Will trigger a pass if the perl regex pattern + * is not present in subject. Fail if found. + * @param string $pattern Perl regex to look for including + * the regex delimiters. + * @param string $subject String to search in. + * @param string $message Message to display. + * @return boolean True on pass + * @access public + */ + function assertNoPattern($pattern, $subject, $message = '%s', $group = 'Other') { + $dumper = &new SimpleDumper(); + $found = preg_match($pattern, $subject); + $message = sprintf($message, 'Pattern '. $pattern .' not detected in ['. $dumper->describeValue($subject) .']'); + $this->assertFalse($found, $message, $group = 'Other'); + } +}
\ No newline at end of file diff --git a/modules/simpletest/xml.php b/modules/simpletest/xml.php new file mode 100644 index 000000000..f3f105618 --- /dev/null +++ b/modules/simpletest/xml.php @@ -0,0 +1,641 @@ +<?php +// $Id$ + +// --------------------------------------- +// This file will be removed +// --------------------------------------- + + +/** + * Creates the XML needed for remote communication + * by SimpleTest. + * @package SimpleTest + * @subpackage UnitTester + */ +class XmlReporter extends SimpleReporter { + var $_indent; + var $_namespace; + + /** + * Sets up indentation and namespace. + * @param string $namespace Namespace to add to each tag. + * @param string $indent Indenting to add on each nesting. + * @access public + */ + function XmlReporter($namespace = false, $indent = ' ') { + $this->SimpleReporter(); + $this->_namespace = ($namespace ? $namespace . ':' : ''); + $this->_indent = $indent; + } + + /** + * Calculates the pretty printing indent level + * from the current level of nesting. + * @param integer $offset Extra indenting level. + * @return string Leading space. + * @access protected + */ + function _getIndent($offset = 0) { + return str_repeat( + $this->_indent, + count($this->getTestList()) + $offset); + } + + /** + * Converts character string to parsed XML + * entities string. + * @param string text Unparsed character data. + * @return string Parsed character data. + * @access public + */ + function toParsedXml($text) { + return str_replace( + array('&', '<', '>', '"', '\''), + array('&', '<', '>', '"', '''), + $text); + } + + /** + * Paints the start of a group test. + * @param string $test_name Name of test that is starting. + * @param integer $size Number of test cases starting. + * @access public + */ + function paintGroupStart($test_name, $size) { + parent::paintGroupStart($test_name, $size); + print $this->_getIndent(); + print "<" . $this->_namespace . "group size=\"$size\">\n"; + print $this->_getIndent(1); + print "<" . $this->_namespace . "name>" . + $this->toParsedXml($test_name) . + "</" . $this->_namespace . "name>\n"; + } + + /** + * Paints the end of a group test. + * @param string $test_name Name of test that is ending. + * @access public + */ + function paintGroupEnd($test_name) { + print $this->_getIndent(); + print "</" . $this->_namespace . "group>\n"; + parent::paintGroupEnd($test_name); + } + + /** + * Paints the start of a test case. + * @param string $test_name Name of test that is starting. + * @access public + */ + function paintCaseStart($test_name) { + parent::paintCaseStart($test_name); + print $this->_getIndent(); + print "<" . $this->_namespace . "case>\n"; + print $this->_getIndent(1); + print "<" . $this->_namespace . "name>" . + $this->toParsedXml($test_name) . + "</" . $this->_namespace . "name>\n"; + } + + /** + * Paints the end of a test case. + * @param string $test_name Name of test that is ending. + * @access public + */ + function paintCaseEnd($test_name) { + print $this->_getIndent(); + print "</" . $this->_namespace . "case>\n"; + parent::paintCaseEnd($test_name); + } + + /** + * Paints the start of a test method. + * @param string $test_name Name of test that is starting. + * @access public + */ + function paintMethodStart($test_name) { + parent::paintMethodStart($test_name); + print $this->_getIndent(); + print "<" . $this->_namespace . "test>\n"; + print $this->_getIndent(1); + print "<" . $this->_namespace . "name>" . + $this->toParsedXml($test_name) . + "</" . $this->_namespace . "name>\n"; + } + + /** + * Paints the end of a test method. + * @param string $test_name Name of test that is ending. + * @param integer $progress Number of test cases ending. + * @access public + */ + function paintMethodEnd($test_name) { + print $this->_getIndent(); + print "</" . $this->_namespace . "test>\n"; + parent::paintMethodEnd($test_name); + } + + /** + * Paints pass as XML. + * @param string $message Message to encode. + * @access public + */ + function paintPass($message) { + parent::paintPass($message); + print $this->_getIndent(1); + print "<" . $this->_namespace . "pass>"; + print $this->toParsedXml($message); + print "</" . $this->_namespace . "pass>\n"; + } + + /** + * Paints failure as XML. + * @param string $message Message to encode. + * @access public + */ + function paintFail($message) { + parent::paintFail($message); + print $this->_getIndent(1); + print "<" . $this->_namespace . "fail>"; + print $this->toParsedXml($message); + print "</" . $this->_namespace . "fail>\n"; + } + + /** + * Paints error as XML. + * @param string $message Message to encode. + * @access public + */ + function paintError($message) { + parent::paintError($message); + print $this->_getIndent(1); + print "<" . $this->_namespace . "exception>"; + print $this->toParsedXml($message); + print "</" . $this->_namespace . "exception>\n"; + } + + /** + * Paints exception as XML. + * @param Exception $exception Exception to encode. + * @access public + */ + function paintException($exception) { + parent::paintException($exception); + print $this->_getIndent(1); + print "<" . $this->_namespace . "exception>"; + $message = 'Unexpected exception of type [' . get_class($exception) . + '] with message ['. $exception->getMessage() . + '] in ['. $exception->getFile() . + ' line ' . $exception->getLine() . ']'; + print $this->toParsedXml($message); + print "</" . $this->_namespace . "exception>\n"; + } + + /** + * Paints the skipping message and tag. + * @param string $message Text to display in skip tag. + * @access public + */ + function paintSkip($message) { + parent::paintSkip($message); + print $this->_getIndent(1); + print "<" . $this->_namespace . "skip>"; + print $this->toParsedXml($message); + print "</" . $this->_namespace . "skip>\n"; + } + + /** + * Paints a simple supplementary message. + * @param string $message Text to display. + * @access public + */ + function paintMessage($message) { + parent::paintMessage($message); + print $this->_getIndent(1); + print "<" . $this->_namespace . "message>"; + print $this->toParsedXml($message); + print "</" . $this->_namespace . "message>\n"; + } + + /** + * Paints a formatted ASCII message such as a + * variable dump. + * @param string $message Text to display. + * @access public + */ + function paintFormattedMessage($message) { + parent::paintFormattedMessage($message); + print $this->_getIndent(1); + print "<" . $this->_namespace . "formatted>"; + print "<![CDATA[$message]]>"; + print "</" . $this->_namespace . "formatted>\n"; + } + + /** + * Serialises the event object. + * @param string $type Event type as text. + * @param mixed $payload Message or object. + * @access public + */ + function paintSignal($type, &$payload) { + parent::paintSignal($type, $payload); + print $this->_getIndent(1); + print "<" . $this->_namespace . "signal type=\"$type\">"; + print "<![CDATA[" . serialize($payload) . "]]>"; + print "</" . $this->_namespace . "signal>\n"; + } + + /** + * Paints the test document header. + * @param string $test_name First test top level + * to start. + * @access public + * @abstract + */ + function paintHeader($test_name) { + if (! SimpleReporter::inCli()) { + header('Content-type: text/xml'); + } + print "<?xml version=\"1.0\""; + if ($this->_namespace) { + print " xmlns:" . $this->_namespace . + "=\"www.lastcraft.com/SimpleTest/Beta3/Report\""; + } + print "?>\n"; + print "<" . $this->_namespace . "run>\n"; + } + + /** + * Paints the test document footer. + * @param string $test_name The top level test. + * @access public + * @abstract + */ + function paintFooter($test_name) { + print "</" . $this->_namespace . "run>\n"; + } +} + +/** + * Accumulator for incoming tag. Holds the + * incoming test structure information for + * later dispatch to the reporter. + @package SimpleTest + @subpackage UnitTester + */ +class NestingXmlTag { + var $_name; + var $_attributes; + + /** + * Sets the basic test information except + * the name. + * @param hash $attributes Name value pairs. + * @access public + */ + function NestingXmlTag($attributes) { + $this->_name = false; + $this->_attributes = $attributes; + } + + /** + * Sets the test case/method name. + * @param string $name Name of test. + * @access public + */ + function setName($name) { + $this->_name = $name; + } + + /** + * Accessor for name. + * @return string Name of test. + * @access public + */ + function getName() { + return $this->_name; + } + + /** + * Accessor for attributes. + * @return hash All attributes. + * @access protected + */ + function _getAttributes() { + return $this->_attributes; + } +} + +/** + * Accumulator for incoming method tag. Holds the + * incoming test structure information for + * later dispatch to the reporter. + @package SimpleTest + @subpackage UnitTester + */ +class NestingMethodTag extends NestingXmlTag { + + /** + * Sets the basic test information except + * the name. + * @param hash $attributes Name value pairs. + * @access public + */ + function NestingMethodTag($attributes) { + $this->NestingXmlTag($attributes); + } + + /** + * Signals the appropriate start event on the + * listener. + * @param SimpleReporter $listener Target for events. + * @access public + */ + function paintStart(&$listener) { + $listener->paintMethodStart($this->getName()); + } + + /** + * Signals the appropriate end event on the + * listener. + * @param SimpleReporter $listener Target for events. + * @access public + */ + function paintEnd(&$listener) { + $listener->paintMethodEnd($this->getName()); + } +} + +/** + * Accumulator for incoming case tag. Holds the + * incoming test structure information for + * later dispatch to the reporter. + @package SimpleTest + @subpackage UnitTester + */ +class NestingCaseTag extends NestingXmlTag { + + /** + * Sets the basic test information except + * the name. + * @param hash $attributes Name value pairs. + * @access public + */ + function NestingCaseTag($attributes) { + $this->NestingXmlTag($attributes); + } + + /** + * Signals the appropriate start event on the + * listener. + * @param SimpleReporter $listener Target for events. + * @access public + */ + function paintStart(&$listener) { + $listener->paintCaseStart($this->getName()); + } + + /** + * Signals the appropriate end event on the + * listener. + * @param SimpleReporter $listener Target for events. + * @access public + */ + function paintEnd(&$listener) { + $listener->paintCaseEnd($this->getName()); + } +} + +/** + * Accumulator for incoming group tag. Holds the + * incoming test structure information for + * later dispatch to the reporter. + @package SimpleTest + @subpackage UnitTester + */ +class NestingGroupTag extends NestingXmlTag { + + /** + * Sets the basic test information except + * the name. + * @param hash $attributes Name value pairs. + * @access public + */ + function NestingGroupTag($attributes) { + $this->NestingXmlTag($attributes); + } + + /** + * Signals the appropriate start event on the + * listener. + * @param SimpleReporter $listener Target for events. + * @access public + */ + function paintStart(&$listener) { + $listener->paintGroupStart($this->getName(), $this->getSize()); + } + + /** + * Signals the appropriate end event on the + * listener. + * @param SimpleReporter $listener Target for events. + * @access public + */ + function paintEnd(&$listener) { + $listener->paintGroupEnd($this->getName()); + } + + /** + * The size in the attributes. + * @return integer Value of size attribute or zero. + * @access public + */ + function getSize() { + $attributes = $this->_getAttributes(); + if (isset($attributes['SIZE'])) { + return (integer)$attributes['SIZE']; + } + return 0; + } +} + +/** + * Parser for importing the output of the XmlReporter. + * Dispatches that output to another reporter. + @package SimpleTest + @subpackage UnitTester + */ +class SimpleTestXmlParser { + var $_listener; + var $_expat; + var $_tag_stack; + var $_in_content_tag; + var $_content; + var $_attributes; + + /** + * Loads a listener with the SimpleReporter + * interface. + * @param SimpleReporter $listener Listener of tag events. + * @access public + */ + function SimpleTestXmlParser(&$listener) { + $this->_listener = &$listener; + $this->_expat = &$this->_createParser(); + $this->_tag_stack = array(); + $this->_in_content_tag = false; + $this->_content = ''; + $this->_attributes = array(); + } + + /** + * Parses a block of XML sending the results to + * the listener. + * @param string $chunk Block of text to read. + * @return boolean True if valid XML. + * @access public + */ + function parse($chunk) { + if (! xml_parse($this->_expat, $chunk)) { + trigger_error('XML parse error with ' . + xml_error_string(xml_get_error_code($this->_expat))); + return false; + } + return true; + } + + /** + * Sets up expat as the XML parser. + * @return resource Expat handle. + * @access protected + */ + function &_createParser() { + $expat = xml_parser_create(); + xml_set_object($expat, $this); + xml_set_element_handler($expat, '_startElement', '_endElement'); + xml_set_character_data_handler($expat, '_addContent'); + xml_set_default_handler($expat, '_default'); + return $expat; + } + + /** + * Opens a new test nesting level. + * @return NestedXmlTag The group, case or method tag + * to start. + * @access private + */ + function _pushNestingTag($nested) { + array_unshift($this->_tag_stack, $nested); + } + + /** + * Accessor for current test structure tag. + * @return NestedXmlTag The group, case or method tag + * being parsed. + * @access private + */ + function &_getCurrentNestingTag() { + return $this->_tag_stack[0]; + } + + /** + * Ends a nesting tag. + * @return NestedXmlTag The group, case or method tag + * just finished. + * @access private + */ + function _popNestingTag() { + return array_shift($this->_tag_stack); + } + + /** + * Test if tag is a leaf node with only text content. + * @param string $tag XML tag name. + * @return @boolean True if leaf, false if nesting. + * @private + */ + function _isLeaf($tag) { + return in_array($tag, array( + 'NAME', 'PASS', 'FAIL', 'EXCEPTION', 'SKIP', 'MESSAGE', 'FORMATTED', 'SIGNAL')); + } + + /** + * Handler for start of event element. + * @param resource $expat Parser handle. + * @param string $tag Element name. + * @param hash $attributes Name value pairs. + * Attributes without content + * are marked as true. + * @access protected + */ + function _startElement($expat, $tag, $attributes) { + $this->_attributes = $attributes; + if ($tag == 'GROUP') { + $this->_pushNestingTag(new NestingGroupTag($attributes)); + } elseif ($tag == 'CASE') { + $this->_pushNestingTag(new NestingCaseTag($attributes)); + } elseif ($tag == 'TEST') { + $this->_pushNestingTag(new NestingMethodTag($attributes)); + } elseif ($this->_isLeaf($tag)) { + $this->_in_content_tag = true; + $this->_content = ''; + } + } + + /** + * End of element event. + * @param resource $expat Parser handle. + * @param string $tag Element name. + * @access protected + */ + function _endElement($expat, $tag) { + $this->_in_content_tag = false; + if (in_array($tag, array('GROUP', 'CASE', 'TEST'))) { + $nesting_tag = $this->_popNestingTag(); + $nesting_tag->paintEnd($this->_listener); + } elseif ($tag == 'NAME') { + $nesting_tag = &$this->_getCurrentNestingTag(); + $nesting_tag->setName($this->_content); + $nesting_tag->paintStart($this->_listener); + } elseif ($tag == 'PASS') { + $this->_listener->paintPass($this->_content); + } elseif ($tag == 'FAIL') { + $this->_listener->paintFail($this->_content); + } elseif ($tag == 'EXCEPTION') { + $this->_listener->paintError($this->_content); + } elseif ($tag == 'SKIP') { + $this->_listener->paintSkip($this->_content); + } elseif ($tag == 'SIGNAL') { + $this->_listener->paintSignal( + $this->_attributes['TYPE'], + unserialize($this->_content)); + } elseif ($tag == 'MESSAGE') { + $this->_listener->paintMessage($this->_content); + } elseif ($tag == 'FORMATTED') { + $this->_listener->paintFormattedMessage($this->_content); + } + } + + /** + * Content between start and end elements. + * @param resource $expat Parser handle. + * @param string $text Usually output messages. + * @access protected + */ + function _addContent($expat, $text) { + if ($this->_in_content_tag) { + $this->_content .= $text; + } + return true; + } + + /** + * XML and Doctype handler. Discards all such content. + * @param resource $expat Parser handle. + * @param string $default Text of default content. + * @access protected + */ + function _default($expat, $default) { + } +} + |