:). */ protected $httpauth_credentials = NULL; /** * The current session name, if available. */ protected $session_name = NULL; /** * The current session ID, if available. */ protected $session_id = NULL; /** * Whether the files were copied to the test files directory. */ protected $generatedTestFiles = FALSE; /** * The maximum number of redirects to follow when handling responses. */ protected $maximumRedirects = 5; /** * The number of redirects followed during the handling of a request. */ protected $redirect_count; /** * The kernel used in this test. */ protected $kernel; /** * Constructor for Drupal\simpletest\WebTestBase. */ function __construct($test_id = NULL) { parent::__construct($test_id); $this->skipClasses[__CLASS__] = TRUE; } /** * Get a node from the database based on its title. * * @param $title * A node title, usually generated by $this->randomName(). * @param $reset * (optional) Whether to reset the entity cache. * * @return * A node entity matching $title. */ function drupalGetNodeByTitle($title, $reset = FALSE) { if ($reset) { drupal_container()->get('plugin.manager.entity')->getStorageController('node')->resetCache(); } $nodes = entity_load_multiple_by_properties('node', array('title' => $title)); // Load the first node returned from the database. $returned_node = reset($nodes); return $returned_node; } /** * Creates a node based on default settings. * * @param array $settings * (optional) An associative array of settings for the node, as used in * entity_create(). Override the defaults by specifying the key and value * in the array, for example: * @code * $this->drupalCreateNode(array( * 'title' => t('Hello, world!'), * 'type' => 'article', * )); * @endcode * The following defaults are provided: * - body: Random string using the default filter format: * @code * $settings['body'][LANGUAGE_NOT_SPECIFIED][0] = array( * 'value' => $this->randomName(32), * 'format' => filter_default_format(), * ); * @endcode * - title: Random string. * - comment: COMMENT_NODE_OPEN. * - changed: REQUEST_TIME. * - promote: NODE_NOT_PROMOTED. * - log: Empty string. * - status: NODE_PUBLISHED. * - sticky: NODE_NOT_STICKY. * - type: 'page'. * - langcode: LANGUAGE_NOT_SPECIFIED. (If a 'langcode' key is provided in * the array, this language code will also be used for a randomly * generated body field for that language, and the body for * LANGUAGE_NOT_SPECIFIED will remain empty.) * - uid: The currently logged in user, or the user running test. * - revision: 1. (Backwards-compatible binary flag indicating whether a * new revision should be created; use 1 to specify a new revision.) * * @return Drupal\node\Node * The created node entity. */ protected function drupalCreateNode(array $settings = array()) { // Populate defaults array. $settings += array( 'body' => array(LANGUAGE_NOT_SPECIFIED => array(array())), 'title' => $this->randomName(8), 'changed' => REQUEST_TIME, 'promote' => NODE_NOT_PROMOTED, 'revision' => 1, 'log' => '', 'status' => NODE_PUBLISHED, 'sticky' => NODE_NOT_STICKY, 'type' => 'page', 'langcode' => LANGUAGE_NOT_SPECIFIED, ); // Add in comment settings for nodes. if (module_exists('comment')) { $settings += array( 'comment' => COMMENT_NODE_OPEN, ); } // Use the original node's created time for existing nodes. if (isset($settings['created']) && !isset($settings['date'])) { $settings['date'] = format_date($settings['created'], 'custom', 'Y-m-d H:i:s O'); } // If the node's user uid is not specified manually, use the currently // logged in user if available, or else the user running the test. if (!isset($settings['uid'])) { if ($this->loggedInUser) { $settings['uid'] = $this->loggedInUser->uid; } else { global $user; $settings['uid'] = $user->uid; } } // Merge body field value and format separately. $body = array( 'value' => $this->randomName(32), 'format' => filter_default_format(), ); if (empty($settings['body'][$settings['langcode']])) { $settings['body'][$settings['langcode']][0] = array(); } $settings['body'][$settings['langcode']][0] += $body; $node = entity_create('node', $settings); if (!empty($settings['revision'])) { $node->setNewRevision(); } $node->save(); // Small hack to link revisions to our test user. db_update('node_revision') ->fields(array('uid' => $node->uid)) ->condition('vid', $node->vid) ->execute(); 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 * Created content type. */ protected function drupalCreateContentType($settings = array()) { // Find a non-existent random type name. do { $name = strtolower($this->randomName(8)); } while (node_type_load($name)); // Populate defaults array. $defaults = array( 'type' => $name, 'name' => $name, 'base' => 'node_content', 'description' => '', 'help' => '', '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; $saved_type = node_type_save($type); node_types_rebuild(); menu_router_rebuild(); node_add_body_field($type); $this->assertEqual($saved_type, SAVED_NEW, t('Created content type %type.', array('%type' => $type->type))); // Reset permissions so that permissions for this content type are available. $this->checkPermissions(array(), TRUE); return $type; } /** * Creates a block instance based on default settings. * * Note: Until this can be done programmatically, the active user account * must have permission to administer blocks. * * @param string $plugin_id * The plugin ID of the block type for this block instance. * @param array $values * (optional) An associative array of values for the block entity. * Override the defaults by specifying the key and value in the array, for * example: * @code * $this->drupalPlaceBlock('system_powered_by_block', array( * 'label' => t('Hello, world!'), * )); * @endcode * The following defaults are provided: * - label: Random string. * - machine_name: Random string. * - region: 'sidebar_first'. * - theme: The default theme. * @param array $settings * (optional) An associative array of plugin-specific settings. * * @return \Drupal\block\Plugin\Core\Entity\Block * The block entity. * * @todo * Add support for creating custom block instances. */ protected function drupalPlaceBlock($plugin_id, array $values = array(), array $settings = array()) { $values += array( 'plugin' => $plugin_id, 'label' => $this->randomName(8), 'region' => 'sidebar_first', 'theme' => variable_get('theme_default', 'stark'), 'machine_name' => strtolower($this->randomName(8)), 'settings' => $settings, ); // Build the ID out of the theme and machine_name. $values['id'] = $values['theme'] . '.' . $values['machine_name']; $block = entity_create('block', $values); $block->save(); return $block; } /** * Get a list files that can be used in tests. * * @param $type * File type, possible values: 'binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'. * @param $size * File size in bytes to match. Please check the tests/files folder. * @return * List of files that match filter. */ protected function drupalGetTestFiles($type, $size = NULL) { if (empty($this->generatedTestFiles)) { // Generate binary test files. $lines = array(64, 1024); $count = 0; foreach ($lines as $line) { simpletest_generate_file('binary-' . $count++, 64, $line, 'binary'); } // Generate text test files. $lines = array(16, 256, 1024, 2048, 20480); $count = 0; foreach ($lines as $line) { simpletest_generate_file('text-' . $count++, 64, $line); } // Copy other test files from simpletest. $original = drupal_get_path('module', 'simpletest') . '/files'; $files = file_scan_directory($original, '/(html|image|javascript|php|sql)-.*/'); foreach ($files as $file) { file_unmanaged_copy($file->uri, variable_get('file_public_path', conf_path() . '/files')); } $this->generatedTestFiles = TRUE; } $files = array(); // Make sure type is valid. if (in_array($type, array('binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'))) { $files = file_scan_directory('public://', '/' . $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->uri); if ($stats['size'] != $size) { unset($files[$file->uri]); } } } } usort($files, array($this, 'drupalCompareFiles')); return $files; } /** * Compare two files based on size and file name. */ protected function drupalCompareFiles($file1, $file2) { $compare_size = filesize($file1->uri) - filesize($file2->uri); if ($compare_size) { // Sort by file size. return $compare_size; } else { // The files were the same size, so sort alphabetically. return strnatcmp($file1->name, $file2->name); } } /** * Create a user with a given set of permissions. * * @param array $permissions * Array of permission names to assign to user. Note that the user always * has the default permissions derived from the "authenticated users" role. * * @return object|false * A fully loaded user object with pass_raw property, or FALSE if account * creation fails. */ protected function drupalCreateUser(array $permissions = array()) { // Create a role with the given permission set, if any. $rid = FALSE; if ($permissions) { $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['pass'] = user_password(); $edit['status'] = 1; if ($rid) { $edit['roles'] = array($rid => $rid); } $account = entity_create('user', $edit); $account->save(); $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. * @param string $rid * (optional) The role ID (machine name). Defaults to a random name. * @param string $name * (optional) The label for the role. Defaults to a random string. * @param integer $weight * (optional) The weight for the role. Defaults NULL so that entity_create() * sets the weight to maximum + 1. * * @return string * Role ID of newly created role, or FALSE if role creation failed. */ protected function drupalCreateRole(array $permissions, $rid = NULL, $name = NULL, $weight = NULL) { // Generate a random, lowercase machine name if none was passed. if (!isset($rid)) { $rid = strtolower($this->randomName(8)); } // Generate a random label. if (!isset($name)) { $name = $this->randomString(8); } // Check the all the permissions strings are valid. if (!$this->checkPermissions($permissions)) { return FALSE; } // Create new role. $role = entity_create('user_role', array( 'id' => $rid, 'label' => $name, )); if (!is_null($weight)) { $role->set('weight', $weight); } $result = $role->save(); $this->assertIdentical($result, SAVED_NEW, t('Created role ID @rid with name @name.', array( '@name' => var_export($role->label(), TRUE), '@rid' => var_export($role->id(), TRUE), )), t('Role')); if ($result === SAVED_NEW) { // Grant the specified permissions to the role, if any. if (!empty($permissions)) { user_role_grant_permissions($role->id(), $permissions); $assigned_permissions = db_query('SELECT permission FROM {role_permission} WHERE rid = :rid', array(':rid' => $role->id()))->fetchCol(); $missing_permissions = array_diff($permissions, $assigned_permissions); if (!$missing_permissions) { $this->pass(t('Created permissions: @perms', array('@perms' => implode(', ', $permissions))), t('Role')); } else { $this->fail(t('Failed to create permissions: @perms', array('@perms' => implode(', ', $missing_permissions))), t('Role')); } } return $role->id(); } else { return FALSE; } } /** * Check to make sure that the array of permissions are valid. * * @param $permissions * Permissions to check. * @param $reset * Reset cached available permissions. * @return * TRUE or FALSE depending on whether the permissions are valid. */ protected function checkPermissions(array $permissions, $reset = FALSE) { $available = &drupal_static(__FUNCTION__); if (!isset($available) || $reset) { $available = array_keys(module_invoke_all('permission')); } $valid = TRUE; foreach ($permissions as $permission) { if (!in_array($permission, $available)) { $this->fail(t('Invalid permission %permission.', array('%permission' => $permission)), t('Role')); $valid = FALSE; } } return $valid; } /** * Log in a user with the internal browser. * * If a user is already logged in, then the current user is logged out before * logging in the specified user. * * Please note that neither the global $user nor the passed-in user object is * populated with data of the logged in user. If you need full access to the * user object after logging in, it must be updated manually. If you also need * access to the plain-text password of the user (set by drupalCreateUser()), * e.g. to log in the same user again, then it must be re-assigned manually. * For example: * @code * // Create a user. * $account = $this->drupalCreateUser(array()); * $this->drupalLogin($account); * // Load real user object. * $pass_raw = $account->pass_raw; * $account = user_load($account->uid); * $account->pass_raw = $pass_raw; * @endcode * * @param $user * User object representing the user to log in. * * @see drupalCreateUser() */ protected function drupalLogin($user) { if ($this->loggedInUser) { $this->drupalLogout(); } $edit = array( 'name' => $user->name, 'pass' => $user->pass_raw ); $this->drupalPost('user', $edit, t('Log in')); // If a "log out" link appears on the page, it is almost certainly because // the login was successful. $pass = $this->assertLink(t('Log out'), 0, t('User %name successfully logged in.', array('%name' => $user->name)), t('User login')); if ($pass) { $this->loggedInUser = $user; } } /** * Generate a token for the currently logged in user. */ protected function drupalGetToken($value = '') { $private_key = drupal_get_private_key(); return drupal_hmac_base64($value, $this->session_id . $private_key); } /* * Logs a user out of the internal browser, then check the login page to confirm logout. */ protected function drupalLogout() { // Make a request to the logout page, and redirect to the user page, the // idea being if you were properly logged out you should be seeing a login // screen. $this->drupalGet('user/logout', array('query' => array('destination' => 'user'))); $this->assertResponse(200, t('User was logged out.')); $pass = $this->assertField('name', t('Username field found.'), t('Logout')); $pass = $pass && $this->assertField('pass', t('Password field found.'), t('Logout')); if ($pass) { $this->loggedInUser = FALSE; } } /** * Sets up a Drupal site for running functional and integration tests. * * Generates a random database prefix and installs Drupal with the specified * installation profile in Drupal\simpletest\WebTestBase::$profile into the * prefixed database. Afterwards, installs any additional modules specified by * the test. * * After installation all caches are flushed and several configuration values * are reset to the values of the parent site executing the test, since the * default values may be incompatible with the environment in which tests are * being executed. * * @param ... * List of modules to enable for the duration of the test. This can be * either a single array or a variable number of string arguments. * * @see Drupal\simpletest\WebTestBase::prepareDatabasePrefix() * @see Drupal\simpletest\WebTestBase::changeDatabasePrefix() * @see Drupal\simpletest\WebTestBase::prepareEnvironment() */ protected function setUp() { global $user, $conf; // When running tests through the Simpletest UI (vs. on the command line), // Simpletest's batch conflicts with the installer's batch. Batch API does // not support the concept of nested batches (in which the nested is not // progressive), so we need to temporarily pretend there was no batch. // Backup the currently running Simpletest batch. $this->originalBatch = batch_get(); // Create the database prefix for this test. $this->prepareDatabasePrefix(); // Prepare the environment for running tests. $this->prepareEnvironment(); if (!$this->setupEnvironment) { return FALSE; } // Reset all statics and variables to perform tests in a clean environment. $conf = array(); drupal_static_reset(); // Change the database prefix. // All static variables need to be reset before the database prefix is // changed, since Drupal\Core\Utility\CacheArray implementations attempt to // write back to persistent caches when they are destructed. $this->changeDatabasePrefix(); if (!$this->setupDatabasePrefix) { return FALSE; } // Set the 'simpletest_parent_profile' variable to add the parent profile's // search path to the child site's search paths. // @see drupal_system_listing() $conf['simpletest_parent_profile'] = $this->originalProfile; // Set installer parameters. // @see install.php, install.core.inc $connection_info = Database::getConnectionInfo(); $this->root_user = (object) array( 'name' => 'admin', 'mail' => 'admin@example.com', 'pass_raw' => $this->randomName(), ); $settings = array( 'interactive' => FALSE, 'parameters' => array( 'profile' => $this->profile, 'langcode' => 'en', ), 'forms' => array( 'install_settings_form' => $connection_info['default'], 'install_configure_form' => array( 'site_name' => 'Drupal', 'site_mail' => 'simpletest@example.com', 'account' => array( 'name' => $this->root_user->name, 'mail' => $this->root_user->mail, 'pass' => array( 'pass1' => $this->root_user->pass_raw, 'pass2' => $this->root_user->pass_raw, ), ), // form_type_checkboxes_value() requires NULL instead of FALSE values // for programmatic form submissions to disable a checkbox. 'update_status_module' => array( 1 => NULL, 2 => NULL, ), ), ), ); // Replace the global $user session with an anonymous user to resemble a // regular installation. $user = drupal_anonymous_user(); // Reset the static batch to remove Simpletest's batch operations. $batch = &batch_get(); $batch = array(); $variables = array( 'file_public_path' => $this->public_files_directory, 'file_private_path' => $this->private_files_directory, 'file_temporary_path' => $this->temp_files_directory, 'locale_translate_file_directory' => $this->translation_files_directory, ); foreach ($variables as $name => $value) { $GLOBALS['conf'][$name] = $value; } // Execute the non-interactive installer. require_once DRUPAL_ROOT . '/core/includes/install.core.inc'; install_drupal($settings); $this->rebuildContainer(); foreach ($variables as $name => $value) { variable_set($name, $value); } // Restore the original Simpletest batch. $batch = &batch_get(); $batch = $this->originalBatch; // Revert install_begin_request() cache service overrides. unset($conf['cache_classes']); // Set path variables. // Set 'parent_profile' of simpletest to add the parent profile's // search path to the child site's search paths. // @see drupal_system_listing() config('simpletest.settings')->set('parent_profile', $this->originalProfile)->save(); // Collect modules to install. $class = get_class($this); $modules = array(); while ($class) { if (property_exists($class, 'modules')) { $modules = array_merge($modules, $class::$modules); } $class = get_parent_class($class); } if ($modules) { $success = module_enable($modules, TRUE); $this->assertTrue($success, t('Enabled modules: %modules', array('%modules' => implode(', ', $modules)))); $this->rebuildContainer(); } // Reset/rebuild all data structures after enabling the modules. $this->resetAll(); // Use the test mail class instead of the default mail handler class. variable_set('mail_system', array('default-system' => 'Drupal\Core\Mail\VariableLog')); drupal_set_time_limit($this->timeLimit); // Temporary fix so that when running from run-tests.sh we don't get an // empty current path which would indicate we're on the home page. $path = current_path(); if (empty($path)) { _current_path('run-tests'); } $this->setup = TRUE; } /** * Reset all data structures after having enabled new modules. * * This method is called by Drupal\simpletest\WebTestBase::setUp() after enabling * the requested modules. It must be called again when additional modules * are enabled later. */ protected function resetAll() { // Clear all database and static caches and rebuild data structures. drupal_flush_all_caches(); // Reload global $conf array and permissions. $this->refreshVariables(); $this->checkPermissions(array(), TRUE); } /** * Refresh the in-memory set of variables. Useful after a page request is made * that changes a variable in a different thread. * * In other words calling a settings page with $this->drupalPost() with a changed * value would update a variable to reflect that change, but in the thread that * made the call (thread running the test) the changed variable would not be * picked up. * * This method clears the variables cache and loads a fresh copy from the database * to ensure that the most up-to-date set of variables is loaded. */ protected function refreshVariables() { global $conf; cache('bootstrap')->delete('variables'); $conf = variable_initialize(); drupal_container()->get('config.factory')->reset(); } /** * Delete created files and temporary files directory, delete the tables created by setUp(), * and reset the database prefix. */ protected function tearDown() { // Destroy the testing kernel. if (isset($this->kernel)) { $this->kernel->shutdown(); } parent::tearDown(); // Ensure that internal logged in variable and cURL options are reset. $this->loggedInUser = FALSE; $this->additionalCurlOptions = array(); // Close the CURL handler. $this->curlClose(); } /** * Initializes the cURL connection. * * If the simpletest_httpauth_credentials variable is set, this function will * add HTTP authentication headers. This is necessary for testing sites that * are protected by login credentials from public access. * See the description of $curl_options for other options. */ protected function curlInitialize() { global $base_url; if (!isset($this->curlHandle)) { $this->curlHandle = curl_init(); // Some versions/configurations of cURL break on a NULL cookie jar, so // supply a real file. if (empty($this->cookieFile)) { $this->cookieFile = $this->public_files_directory . '/cookie.jar'; } $curl_options = array( CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_URL => $base_url, CURLOPT_FOLLOWLOCATION => FALSE, CURLOPT_RETURNTRANSFER => TRUE, CURLOPT_SSL_VERIFYPEER => FALSE, // Required to make the tests run on HTTPS. CURLOPT_SSL_VERIFYHOST => FALSE, // Required to make the tests run on HTTPS. CURLOPT_HEADERFUNCTION => array(&$this, 'curlHeaderCallback'), CURLOPT_USERAGENT => $this->databasePrefix, ); if (isset($this->httpauth_credentials)) { $curl_options[CURLOPT_HTTPAUTH] = $this->httpauth_method; $curl_options[CURLOPT_USERPWD] = $this->httpauth_credentials; } // curl_setopt_array() returns FALSE if any of the specified options // cannot be set, and stops processing any further options. $result = curl_setopt_array($this->curlHandle, $this->additionalCurlOptions + $curl_options); if (!$result) { throw new \UnexpectedValueException('One or more cURL options could not be set.'); } // By default, the child session name should be the same as the parent. $this->session_name = session_name(); } // We set the user agent header on each request so as to use the current // time and a new uniqid. if (preg_match('/simpletest\d+/', $this->databasePrefix, $matches)) { curl_setopt($this->curlHandle, CURLOPT_USERAGENT, drupal_generate_test_ua($matches[0])); } } /** * Initializes and executes a cURL request. * * @param $curl_options * An associative array of cURL options to set, where the keys are constants * defined by the cURL library. For a list of valid options, see * http://www.php.net/manual/function.curl-setopt.php * @param $redirect * FALSE if this is an initial request, TRUE if this request is the result * of a redirect. * * @return * The content returned from the call to curl_exec(). * * @see curlInitialize() */ protected function curlExec($curl_options, $redirect = FALSE) { $this->curlInitialize(); // cURL incorrectly handles URLs with a fragment by including the // fragment in the request to the server, causing some web servers // to reject the request citing "400 - Bad Request". To prevent // this, we strip the fragment from the request. // TODO: Remove this for Drupal 8, since fixed in curl 7.20.0. if (!empty($curl_options[CURLOPT_URL]) && strpos($curl_options[CURLOPT_URL], '#')) { $original_url = $curl_options[CURLOPT_URL]; $curl_options[CURLOPT_URL] = strtok($curl_options[CURLOPT_URL], '#'); } $url = empty($curl_options[CURLOPT_URL]) ? curl_getinfo($this->curlHandle, CURLINFO_EFFECTIVE_URL) : $curl_options[CURLOPT_URL]; if (!empty($curl_options[CURLOPT_POST])) { // This is a fix for the Curl library to prevent Expect: 100-continue // headers in POST requests, that may cause unexpected HTTP response // codes from some webservers (like lighttpd that returns a 417 error // code). It is done by setting an empty "Expect" header field that is // not overwritten by Curl. $curl_options[CURLOPT_HTTPHEADER][] = 'Expect:'; } curl_setopt_array($this->curlHandle, $this->additionalCurlOptions + $curl_options); if (!$redirect) { // Reset headers, the session ID and the redirect counter. $this->session_id = NULL; $this->headers = array(); $this->redirect_count = 0; } $content = curl_exec($this->curlHandle); $status = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); // cURL incorrectly handles URLs with fragments, so instead of // letting cURL handle redirects we take of them ourselves to // to prevent fragments being sent to the web server as part // of the request. // TODO: Remove this for Drupal 8, since fixed in curl 7.20.0. if (in_array($status, array(300, 301, 302, 303, 305, 307)) && $this->redirect_count < $this->maximumRedirects) { if ($this->drupalGetHeader('location')) { $this->redirect_count++; $curl_options = array(); $curl_options[CURLOPT_URL] = $this->drupalGetHeader('location'); $curl_options[CURLOPT_HTTPGET] = TRUE; return $this->curlExec($curl_options, TRUE); } } $this->drupalSetContent($content, isset($original_url) ? $original_url : curl_getinfo($this->curlHandle, CURLINFO_EFFECTIVE_URL)); $message_vars = array( '!method' => !empty($curl_options[CURLOPT_NOBODY]) ? 'HEAD' : (empty($curl_options[CURLOPT_POSTFIELDS]) ? 'GET' : 'POST'), '@url' => isset($original_url) ? $original_url : $url, '@status' => $status, '!length' => format_size(strlen($this->drupalGetContent())) ); $message = t('!method @url returned @status (!length).', $message_vars); $this->assertTrue($this->drupalGetContent() !== FALSE, $message, t('Browser')); return $this->drupalGetContent(); } /** * Reads headers and registers errors received from the tested site. * * @see _drupal_log_error(). * * @param $curlHandler * The cURL handler. * @param $header * An header. */ protected function curlHeaderCallback($curlHandler, $header) { // Header fields can be extended over multiple lines by preceding each // extra line with at least one SP or HT. They should be joined on receive. // Details are in RFC2616 section 4. if ($header[0] == ' ' || $header[0] == "\t") { // Normalize whitespace between chucks. $this->headers[] = array_pop($this->headers) . ' ' . trim($header); } else { $this->headers[] = $header; } // Errors are being sent via X-Drupal-Assertion-* headers, // generated by _drupal_log_error() in the exact form required // by Drupal\simpletest\WebTestBase::error(). if (preg_match('/^X-Drupal-Assertion-[0-9]+: (.*)$/', $header, $matches)) { // Call Drupal\simpletest\WebTestBase::error() with the parameters from the header. call_user_func_array(array(&$this, 'error'), unserialize(urldecode($matches[1]))); } // Save cookies. if (preg_match('/^Set-Cookie: ([^=]+)=(.+)/', $header, $matches)) { $name = $matches[1]; $parts = array_map('trim', explode(';', $matches[2])); $value = array_shift($parts); $this->cookies[$name] = array('value' => $value, 'secure' => in_array('secure', $parts)); if ($name == $this->session_name) { if ($value != 'deleted') { $this->session_id = $value; } else { $this->session_id = NULL; } } } // This is required by cURL. return strlen($header); } /** * Close the cURL handler and unset the handler. */ protected function curlClose() { if (isset($this->curlHandle)) { curl_close($this->curlHandle); unset($this->curlHandle); } } /** * Parse content returned from curlExec using DOM and SimpleXML. * * @return * A SimpleXMLElement or FALSE on failure. */ protected function parse() { if (!$this->elements) { // DOM can load HTML soup. But, HTML soup can throw warnings, suppress // them. $htmlDom = new DOMDocument(); @$htmlDom->loadHTML('' . $this->drupalGetContent()); if ($htmlDom) { $this->pass(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); } } if (!$this->elements) { $this->fail(t('Parsed page successfully.'), t('Browser')); } return $this->elements; } /** * Retrieves a Drupal path or an absolute path. * * @param $path * Drupal path or URL to load into internal browser * @param $options * Options to be forwarded to url(). * @param $headers * An array containing additional HTTP request headers, each formatted as * "name: value". * @return * The retrieved HTML string, also available as $this->drupalGetContent() */ protected function drupalGet($path, array $options = array(), array $headers = array()) { $options['absolute'] = TRUE; // We re-using a CURL connection here. If that connection still has certain // options set, it might change the GET into a POST. Make sure we clear out // previous options. $out = $this->curlExec(array(CURLOPT_HTTPGET => TRUE, CURLOPT_URL => url($path, $options), CURLOPT_NOBODY => FALSE, CURLOPT_HTTPHEADER => $headers)); $this->refreshVariables(); // Ensure that any changes to variables in the other thread are picked up. // Replace original page output with new output from redirected page(s). if ($new = $this->checkForMetaRefresh()) { $out = $new; } $this->verbose('GET request to: ' . $path . '
Ending URL: ' . $this->getUrl() . '
' . $out); return $out; } /** * Retrieve a Drupal path or an absolute path and JSON decode the result. */ protected function drupalGetAJAX($path, array $options = array(), array $headers = array()) { $headers[] = 'X-Requested-With: XMLHttpRequest'; return drupal_json_decode($this->drupalGet($path, $options, $headers)); } /** * Execute a POST request on a Drupal page. * It will be done as usual POST request with SimpleBrowser. * * @param $path * Location of the post form. Either a Drupal path or an absolute path or * NULL to post to the current page. For multi-stage forms you can set the * path to NULL and have it post to the last received page. Example: * * @code * // First step in form. * $edit = array(...); * $this->drupalPost('some_url', $edit, t('Save')); * * // Second step in form. * $edit = array(...); * $this->drupalPost(NULL, $edit, t('Save')); * @endcode * @param $edit * Field data in an associative array. Changes the current input fields * (where possible) to the values indicated. A checkbox can be set to * TRUE to be checked and should be set to FALSE to be unchecked. Note that * when a form contains file upload fields, other fields cannot start with * the '@' character. * * Multiple select fields can be set using name[] and setting each of the * possible values. Example: * @code * $edit = array(); * $edit['name[]'] = array('value1', 'value2'); * @endcode * @param $submit * Value of the submit button whose click is to be emulated. For example, * t('Save'). The processing of the request depends on this value. For * example, a form may have one button with the value t('Save') and another * button with the value t('Delete'), and execute different code depending * on which one is clicked. * * This function can also be called to emulate an Ajax submission. In this * case, this value needs to be an array with the following keys: * - path: A path to submit the form values to for Ajax-specific processing, * which is likely different than the $path parameter used for retrieving * the initial form. Defaults to 'system/ajax'. * - triggering_element: If the value for the 'path' key is 'system/ajax' or * another generic Ajax processing path, this needs to be set to the name * of the element. If the name doesn't identify the element uniquely, then * this should instead be an array with a single key/value pair, * corresponding to the element name and value. The callback for the * generic Ajax processing path uses this to find the #ajax information * for the element, including which specific callback to use for * processing the request. * * This can also be set to NULL in order to emulate an Internet Explorer * submission of a form with a single text field, and pressing ENTER in that * textfield: under these conditions, no button information is added to the * POST data. * @param $options * Options to be forwarded to url(). * @param $headers * An array containing additional HTTP request headers, each formatted as * "name: value". * @param $form_html_id * (optional) HTML ID of the form to be submitted. On some pages * there are many identical forms, so just using the value of the submit * button is not enough. For example: 'trigger-node-presave-assign-form'. * Note that this is not the Drupal $form_id, but rather the HTML ID of the * form, which is typically the same thing but with hyphens replacing the * underscores. * @param $extra_post * (optional) A string of additional data to append to the POST submission. * This can be used to add POST data for which there are no HTML fields, as * is done by drupalPostAJAX(). This string is literally appended to the * POST data, so it must already be urlencoded and contain a leading "&" * (e.g., "&extra_var1=hello+world&extra_var2=you%26me"). */ protected function drupalPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) { $submit_matches = FALSE; $ajax = is_array($submit); if (isset($path)) { $this->drupalGet($path, $options); } if ($this->parse()) { $edit_save = $edit; // Let's iterate over all the forms. $xpath = "//form"; if (!empty($form_html_id)) { $xpath .= "[@id='" . $form_html_id . "']"; } $forms = $this->xpath($xpath); foreach ($forms as $form) { // 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, $ajax ? NULL : $submit, $form); $action = isset($form['action']) ? $this->getAbsoluteUrl((string) $form['action']) : $this->getUrl(); if ($ajax) { $action = $this->getAbsoluteUrl(!empty($submit['path']) ? $submit['path'] : 'system/ajax'); // Ajax callbacks verify the triggering element if necessary, so while // we may eventually want extra code that verifies it in the // handleForm() function, it's not currently a requirement. $submit_matches = TRUE; } // We post only if we managed to handle every field in edit and the // submit button matches. if (!$edit && ($submit_matches || !isset($submit))) { $post_array = $post; if ($upload) { // TODO: cURL handles file uploads for us, but the implementation // is broken. This is a less than elegant workaround. Alternatives // are being explored at #253506. foreach ($upload as $key => $file) { $file = drupal_realpath($file); if ($file && is_file($file)) { $post[$key] = '@' . $file; } } } else { foreach ($post as $key => $value) { // Encode according to application/x-www-form-urlencoded // Both names and values needs to be urlencoded, according to // http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1 $post[$key] = urlencode($key) . '=' . urlencode($value); } $post = implode('&', $post) . $extra_post; } $out = $this->curlExec(array(CURLOPT_URL => $action, CURLOPT_POST => TRUE, CURLOPT_POSTFIELDS => $post, CURLOPT_HTTPHEADER => $headers)); // Ensure that any changes to variables in the other thread are picked up. $this->refreshVariables(); // Replace original page output with new output from redirected page(s). if ($new = $this->checkForMetaRefresh()) { $out = $new; } $this->verbose('POST request to: ' . $path . '
Ending URL: ' . $this->getUrl() . '
Fields: ' . highlight_string('' . $out); return $out; } } // We have not found a form which contained all fields of $edit. foreach ($edit as $name => $value) { $this->fail(t('Failed to set field @name to @value', array('@name' => $name, '@value' => $value))); } if (!$ajax && isset($submit)) { $this->assertTrue($submit_matches, t('Found the @submit button', array('@submit' => $submit))); } $this->fail(t('Found the requested form fields at @path', array('@path' => $path))); } } /** * Execute an Ajax submission. * * This executes a POST as ajax.js does. It uses the returned JSON data, an * array of commands, to update $this->content using equivalent DOM * manipulation as is used by ajax.js. It also returns the array of commands. * * @param $path * Location of the form containing the Ajax enabled element to test. Can be * either a Drupal path or an absolute path or NULL to use the current page. * @param $edit * Field data in an associative array. Changes the current input fields * (where possible) to the values indicated. * @param $triggering_element * The name of the form element that is responsible for triggering the Ajax * functionality to test. May be a string or, if the triggering element is * a button, an associative array where the key is the name of the button * and the value is the button label. i.e.) array('op' => t('Refresh')). * @param $ajax_path * (optional) Override the path set by the Ajax settings of the triggering * element. In the absence of both the triggering element's Ajax path and * $ajax_path 'system/ajax' will be used. * @param $options * (optional) Options to be forwarded to url(). * @param $headers * (optional) An array containing additional HTTP request headers, each * formatted as "name: value". Forwarded to drupalPost(). * @param $form_html_id * (optional) HTML ID of the form to be submitted, use when there is more * than one identical form on the same page and the value of the triggering * element is not enough to identify the form. Note this is not the Drupal * ID of the form but rather the HTML ID of the form. * @param $ajax_settings * (optional) An array of Ajax settings which if specified will be used in * place of the Ajax settings of the triggering element. * * @return * An array of Ajax commands. * * @see drupalPost() * @see ajax.js */ protected function drupalPostAJAX($path, $edit, $triggering_element, $ajax_path = NULL, array $options = array(), array $headers = array(), $form_html_id = NULL, $ajax_settings = NULL) { // Get the content of the initial page prior to calling drupalPost(), since // drupalPost() replaces $this->content. if (isset($path)) { $this->drupalGet($path, $options); } $content = $this->content; $drupal_settings = $this->drupalSettings; $headers[] = 'X-Requested-With: XMLHttpRequest'; // Get the Ajax settings bound to the triggering element. if (!isset($ajax_settings)) { if (is_array($triggering_element)) { $xpath = '//*[@name="' . key($triggering_element) . '" and @value="' . current($triggering_element) . '"]'; } else { $xpath = '//*[@name="' . $triggering_element . '"]'; } if (isset($form_html_id)) { $xpath = '//form[@id="' . $form_html_id . '"]' . $xpath; } $element = $this->xpath($xpath); $element_id = (string) $element[0]['id']; $ajax_settings = $drupal_settings['ajax'][$element_id]; } // Add extra information to the POST data as ajax.js does. $extra_post = ''; if (isset($ajax_settings['submit'])) { foreach ($ajax_settings['submit'] as $key => $value) { $extra_post .= '&' . urlencode($key) . '=' . urlencode($value); } } $ajax_html_ids = array(); foreach ($this->xpath('//*[@id]') as $element) { $ajax_html_ids[] = (string) $element['id']; } if (!empty($ajax_html_ids)) { $extra_post .= '&' . urlencode('ajax_html_ids') . '=' . urlencode(implode(' ', $ajax_html_ids)); } if (isset($drupal_settings['ajaxPageState'])) { $extra_post .= '&' . urlencode('ajax_page_state[theme]') . '=' . urlencode($drupal_settings['ajaxPageState']['theme']); $extra_post .= '&' . urlencode('ajax_page_state[theme_token]') . '=' . urlencode($drupal_settings['ajaxPageState']['theme_token']); foreach ($drupal_settings['ajaxPageState']['css'] as $key => $value) { $extra_post .= '&' . urlencode("ajax_page_state[css][$key]") . '=1'; } foreach ($drupal_settings['ajaxPageState']['js'] as $key => $value) { $extra_post .= '&' . urlencode("ajax_page_state[js][$key]") . '=1'; } } // Unless a particular path is specified, use the one specified by the // Ajax settings, or else 'system/ajax'. if (!isset($ajax_path)) { $ajax_path = isset($ajax_settings['url']) ? $ajax_settings['url'] : 'system/ajax'; } // Submit the POST request. $return = drupal_json_decode($this->drupalPost(NULL, $edit, array('path' => $ajax_path, 'triggering_element' => $triggering_element), $options, $headers, $form_html_id, $extra_post)); // Change the page content by applying the returned commands. if (!empty($ajax_settings) && !empty($return)) { // ajax.js applies some defaults to the settings object, so do the same // for what's used by this function. $ajax_settings += array( 'method' => 'replaceWith', ); // DOM can load HTML soup. But, HTML soup can throw warnings, suppress // them. $dom = new DOMDocument(); @$dom->loadHTML($content); // XPath allows for finding wrapper nodes better than DOM does. $xpath = new DOMXPath($dom); foreach ($return as $command) { switch ($command['command']) { case 'settings': $drupal_settings = drupal_merge_js_settings(array($drupal_settings, $command['settings'])); break; case 'insert': $wrapperNode = NULL; // When a command doesn't specify a selector, use the // #ajax['wrapper'] which is always an HTML ID. if (!isset($command['selector'])) { $wrapperNode = $xpath->query('//*[@id="' . $ajax_settings['wrapper'] . '"]')->item(0); } // @todo Ajax commands can target any jQuery selector, but these are // hard to fully emulate with XPath. For now, just handle 'head' // and 'body', since these are used by ajax_render(). elseif (in_array($command['selector'], array('head', 'body'))) { $wrapperNode = $xpath->query('//' . $command['selector'])->item(0); } if ($wrapperNode) { // ajax.js adds an enclosing DIV to work around a Safari bug. $newDom = new DOMDocument(); @$newDom->loadHTML('
' . $command['data'] . '
'); $newNode = $dom->importNode($newDom->documentElement->firstChild->firstChild, TRUE); $method = isset($command['method']) ? $command['method'] : $ajax_settings['method']; // The "method" is a jQuery DOM manipulation function. Emulate // each one using PHP's DOMNode API. switch ($method) { case 'replaceWith': $wrapperNode->parentNode->replaceChild($newNode, $wrapperNode); break; case 'append': $wrapperNode->appendChild($newNode); break; case 'prepend': // If no firstChild, insertBefore() falls back to // appendChild(). $wrapperNode->insertBefore($newNode, $wrapperNode->firstChild); break; case 'before': $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode); break; case 'after': // If no nextSibling, insertBefore() falls back to // appendChild(). $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode->nextSibling); break; case 'html': foreach ($wrapperNode->childNodes as $childNode) { $wrapperNode->removeChild($childNode); } $wrapperNode->appendChild($newNode); break; } } break; // @todo Add suitable implementations for these commands in order to // have full test coverage of what ajax.js can do. case 'remove': break; case 'changed': break; case 'css': break; case 'data': break; case 'restripe': break; case 'add_css': break; } } $content = $dom->saveHTML(); } $this->drupalSetContent($content); $this->drupalSetSettings($drupal_settings); return $return; } /** * Runs cron in the Drupal installed by Simpletest. */ protected function cronRun() { $this->drupalGet('cron/' . state()->get('system.cron_key')); } /** * Check for meta refresh tag and if found call drupalGet() recursively. This * function looks for the http-equiv attribute to be set to "Refresh" * and is case-sensitive. * * @return * Either the new page content or FALSE. */ protected function checkForMetaRefresh() { if (strpos($this->drupalGetContent(), 'parse()) { $refresh = $this->xpath('//meta[@http-equiv="Refresh"]'); if (!empty($refresh)) { // Parse the content attribute of the meta tag for the format: // "[delay]: URL=[page_to_redirect_to]". if (preg_match('/\d+;\s*URL=(?P.*)/i', $refresh[0]['content'], $match)) { return $this->drupalGet($this->getAbsoluteUrl(decode_entities($match['url']))); } } } return FALSE; } /** * Retrieves only the headers for a Drupal path or an absolute path. * * @param $path * Drupal path or URL to load into internal browser * @param $options * Options to be forwarded to url(). * @param $headers * An array containing additional HTTP request headers, each formatted as * "name: value". * @return * The retrieved headers, also available as $this->drupalGetContent() */ protected function drupalHead($path, array $options = array(), array $headers = array()) { $options['absolute'] = TRUE; $out = $this->curlExec(array(CURLOPT_NOBODY => TRUE, CURLOPT_URL => url($path, $options), CURLOPT_HTTPHEADER => $headers)); $this->refreshVariables(); // Ensure that any changes to variables in the other thread are picked up. return $out; } /** * Handle form input related to drupalPost(). Ensure that the specified fields * exist and attempt to create POST data in the correct manner for the particular * field type. * * @param $post * Reference to array of post values. * @param $edit * Reference to array of edit values to be checked against the form. * @param $submit * Form submit button value. * @param $form * Array of form elements. * @return * 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[not(@disabled)]|.//textarea[not(@disabled)]|.//select[not(@disabled)]'); $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 or the name of the tag itself // for