Index: modules/simpletest/simpletest.info =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/simpletest.info,v retrieving revision 1.6 diff -u -r1.6 simpletest.info --- modules/simpletest/simpletest.info 8 Jun 2009 09:23:53 -0000 1.6 +++ modules/simpletest/simpletest.info 16 Jun 2009 06:38:11 -0000 @@ -14,6 +14,7 @@ files[] = tests/actions.test files[] = tests/batch.test files[] = tests/bootstrap.test +files[] = tests/browser.test files[] = tests/cache.test files[] = tests/common.test files[] = tests/database_test.test Index: modules/system/system.module =================================================================== RCS file: /cvs/drupal/drupal/modules/system/system.module,v retrieving revision 1.711 diff -u -r1.711 system.module --- modules/system/system.module 11 Jun 2009 04:36:22 -0000 1.711 +++ modules/system/system.module 16 Jun 2009 06:38:12 -0000 @@ -243,6 +243,26 @@ } /** + * Implementation of hook_browser_wrapper(). + */ +function system_browser_wrapper() { + return array( + 'stream' => array( + 'file' => 'includes/browser/stream.inc', + 'name' => 'Stream', + 'cookie' => FALSE, + 'methods' => array('GET', 'POST'), + ), + 'curl' => array( + 'file' => 'includes/browser/curl.inc', + 'name' => 'cURL', + 'cookies' => TRUE, + 'methods' => array('GET', 'POST'), + ), + ); +} + +/** * Implement hook_elements(). */ function system_elements() { @@ -1170,7 +1190,7 @@ /** * Updates the records in the system table based on the files array. - * + * * @param $files * An array of files. * @param $type Index: modules/simpletest/tests/browser.test =================================================================== RCS file: modules/simpletest/tests/browser.test diff -N modules/simpletest/tests/browser.test --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/simpletest/tests/browser.test 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,86 @@ + t('Browser - wrapper backends'), + 'description' => t('Test stream and curl backends execution of GET and POST requests.'), + 'group' => t('Browser'), + ); + } + + public function setUp() { + parent::setUp('browser_test'); + } + + /** + * Test stream and curl backends execution of GET and POST requests. + */ + public function testBrowserBackend() { + global $db_prefix; + + foreach (array('stream') as $wrapper) { + $browser = Browser::getInstance($wrapper); + $browser->setUserAgent($db_prefix); + + $string = $this->randomName(); + $edit = array( + 'foo' => $string, + ); + + // Test GET method. + $request = $browser->get(url('browser_test/print/get', array('absolute' => TRUE, 'query' => $edit))); + $this->assertEqual($string, $request['content'], t('String found during GET request ($request)'), $wrapper); + $this->assertEqual($string, $browser->getContent(), t('String found during GET request ($browser)'), $wrapper); + + // Test POST method. + $request = $browser->post(url('browser_test/print/post', array('absolute' => TRUE)), $edit, t('Submit')); + $this->assertEqual($string, $request['content'], t('String found during POST request ($request)'), $wrapper); + $this->assertEqual($string, $browser->getContent(), t('String found during POST request ($browser)'), $wrapper); + } + } +} + +/** + * Test browser page manipulation functionality. + */ +class BrowserPageTestCase extends DrupalWebTestCase { + + public static function getInfo() { + return array( + 'name' => t('Browser - page'), + 'description' => t('Check "BrowserPage" class functionality.'), + 'group' => t('Browser'), + ); + } + + public function setUp() { + parent::setUp('browser_test'); + } + + /** + * Check "BrowserPage" class functionality. + */ + public function testBrowserPage() { + global $db_prefix; + + $browser = Browser::getInstance(); + $browser->setUserAgent($db_prefix); + + $browser->get(url('browser_test/print/post', array('absolute' => TRUE))); + $page = $browser->getPage(); + $input = $page->xpath('//input[@name="foo"]'); + $input = $input[0]; + $this->assertEqual('foo', $input['name'], t('Field "foo" found')); + } +} Index: includes/browser/stream.inc =================================================================== RCS file: includes/browser/stream.inc diff -N includes/browser/stream.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ includes/browser/stream.inc 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,134 @@ +handle)) { + $this->handle = stream_context_create(); + } + } + + public function close() { + + } + + public function getRequestHeaders() { + return $this->request_headers; + } + + public function setRequestHeaders($headers = array()) { + $this->request_headers = $headers; + } + + public function get($url) { + $this->execute($url, array( + 'method' => 'GET', + 'header' => array( + 'Content-Type' => 'application/x-www-form-urlencoded', + ), + )); + + return $this->buildRequest(); + } + + public function post($url, array $fields) { + $this->execute($url, array( + 'method' => 'POST', + 'header' => array( + 'Content-Type' => 'application/x-www-form-urlencoded', + ), + 'content' => http_build_query($fields, NULL, '&'), + )); + + return $this->buildRequest(); + } + + public function request($method) { + return FALSE; + } + + protected function buildRequest() { + if ($this->content !== FALSE) { + return array( + 'url' => $this->url, + 'headers' => $this->headers, + 'content' => $this->content, + ); + } + return FALSE; + } + + protected function execute($url, $options) { + global $http_response_header; + + $this->open(); + + if (!isset($options['header'])) { + $options['header'] = array(); + } + + // Merge default request headers with the passed headers and generate + // header string to be sent in http request. + $headers = $this->request_headers + $options['header']; + $headers['Cookie'] = $this->cookie; + $options['header'] = $this->generateHeaderString($headers); + + // Update the handler options. + stream_context_set_params($this->handle, array( + 'options' => array( + 'http' => $options, + ) + )); + + // Make the request. + $this->content = file_get_contents($url, FALSE, $this->handle); + $this->url = $url; // TODO check headers. + $this->parseHeaders($http_response_header); + + // Check for specific headers. +// if (isset($this->headers['Set-Cookie'])) { // TODO Support an case keys. +// $this->cookie = $this->headers['Set-Cookie']; +// } + } + + protected function generateHeaderString(array $headers) { + $string = ''; + foreach ($headers as $key => $header) { + // Remove blank headers. + if ($header) { + $string .= "$key: $header\r\n"; + } + } + return $string; + } + + protected function parseHeaders(array $headers) { + $this->headers = array(); + foreach ($headers as $header) { + $parts = explode(':', $header, 2); + + // Ensure header line is valid. + if (count($parts) == 2) { + $this->headers[trim($parts[0])] = trim($parts[1]); + } + } + } +} Index: includes/browser/browser.inc =================================================================== RCS file: includes/browser/browser.inc diff -N includes/browser/browser.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ includes/browser/browser.inc 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,378 @@ +wrapper = new $class(); + $this->info = $info; + + $this->setUserAgent('Drupal 7 (+http://drupal.org/)'); + } + + final public static function getInstance($wrapper = 'default') { + // Use the default wrapper specified for the site. + if ($wrapper == 'default') { + $wrapper = variable_get('browser_wrapper', 'stream'); + } + + // Create a browser instance for the specified wrapper if it does not already exist. + if (!isset(self::$browser[$wrapper])) { + // Ensure that the specified wrapper is valid, if not use the default. + $wrappers = module_invoke_all('browser_wrapper'); + if (!array_key_exists($wrapper, $wrappers)) { + $wrapper = variable_get('browser_wrapper', 'stream'); + } + + self::$browser[$wrapper] = new Browser($wrapper, $wrappers[$wrapper]); + } + return self::$browser[$wrapper]; + } + + public function getUserAgent() { + $headers = $this->getRequestHeaders(); + return $headers['User-Agent']; + } + + public function setUserAgent($agent) { + $headers = $this->getRequestHeaders(); + $headers['User-Agent'] = $agent; + $this->wrapper->setRequestHeaders($headers); + } + + public function getRequestHeaders() { + return $this->wrapper->getRequestHeaders(); + } + + public function setRequestHeaders(array $headers) { + $this->wrapper->setRequestHeaders($headers); + } + + /** + * Make an HTTP GET request to the specified URL. + * + * @param $url + * Full URL to retrieve. + * @return + * Associative array... + */ + public function get($url) { + if (!$this->isMethodSupported('GET')) { + return FALSE; + } + + $request = $this->wrapper->get($url); + + // TODO Error check, look for meta refresh, etc. + $this->setState($request['url'], $request['headers'], $request['content']); + return $request; + } + + public function post($url, array $fields, $submit) { + if (!$this->isMethodSupported('POST')) { + return FALSE; + } + + // If URL is set then request the page, otherwise use the current page. + if ($url) { + $this->get($url); + } + else { + $url = $this->url; + } + + if (($page = $this->getPage()) === FALSE) { + return FALSE; + } + + if (($form = $this->findForm($fields, $submit)) === FALSE) { + return FALSE; + } + + // If form specified action then use that for the post url. + if ($form['action']) { + $url = $page->getAbsoluteUrl($form['action']); + } + + $request = $this->wrapper->post($url, $form['post']); + + // TODO Error check, look for meta refresh, etc. + $this->setState($request['url'], $request['headers'], $request['content']); + return $request; + } + + protected function findForm(array $fields, $submit) { + $page = $this->getPage(); + + $forms = $page->getForms(); + foreach ($forms as $form) { + if (($post = $this->processForm($form, $fields, $submit)) !== FALSE) { + $action = (isset($form['action']) ? (string) $form['action'] : FALSE); + return array( + 'action' => $action, + 'post' => $post, + ); + } + } + return FALSE; + } + + protected function processForm($form, $fields, $submit) { + $page = $this->getPage(); + + $post = array(); + $submit_found = FALSE; + $inputs = $page->getInputs($form); + foreach ($inputs as $input) { + $name = (string) $input['name']; + $html_value = isset($input['value']) ? (string) $input['value'] : ''; + + // Get type from input vs textarea and select. + $type = isset($input['type']) ? (string) $input['type'] : $input->getName(); + + if (isset($fields[$name])) { + if ($type == 'file') { + // Make sure the file path is the absolute path. + $file = realpath($fields[$name]); + if ($file && is_file($file)) { + // Signify that the post field is a file in case backend needs to + // perform additional processing. + $post[$name] = '@' . $file; + } + // Known type, field processed. + unset($fields[$name]); + } + elseif (($processed_value = $this->processField($input, $type, $fields[$name], $html_value)) !== NULL) { + // Value may be ommitted (checkbox). + if ($processed_value !== FALSE) { + if (is_array($processed_value)) { + $post += $processed_value; + } + else { + $post[$name] = $processed_value; + } + } + // Known type, field processed. + unset($fields[$name]); + } + } + + // No post value for the field means that: no post field value specified, + // the value does not match the field (checkbox, radio, select), or the + // field is of an unknown type. + if (!isset($post[$name])) { + // No value specified so use default value (value in HTML). + if (($default_value = $this->getDefaultFieldValue($input, $type, $html_value)) !== NULL) { + $post[$name] = $default_value; + unset($fields[$name]); + } + } + + // Check if the + if (($type == 'submit' || $type == 'image') && $submit == $html_value) { + $post[$name] = $html_value; + $submit_found = TRUE; + } + } + + if ($submit_found) { + return $post; + } + return FALSE; + } + + protected function processField($input, $type, $new_value, $html_value) { + switch ($type) { + case 'text': + case 'textarea': + case 'password': + return $new_value; + case 'radio': + if ($new_value == $html_value) { + return $new_value; + } + return NULL; + case 'checkbox': + // If $new_value is set to FALSE then ommit checkbox value, otherwise + // pass original value. + if ($new_value === FALSE) { + return FALSE; + } + return $html_value; + case 'select': + // Remove the ending [] from multi-select element name. + $key = preg_replace('/\[\]$/', '', (string) $input['name']); + + $options = $page->getSelectOptions($input); + $index = 0; + $out = array(); + foreach ($options as $value => $text) { + if (is_array($value)) { + if (in_array($value, $new_value)) { + $out[$key . '[' . $index++ . ']'] = $value; + } + } + elseif ($new_value == $value) { + return $new_value; + } + } + return ($out ? $out : NULL); + default: + return NULL; + } + } + + protected function getDefaultFieldValue($input, $type, $html_value) { + switch ($type) { + case 'textarea': + return (string) $input; + case 'select': + // Remove the ending [] from multi-select element name. + $key = preg_replace('/\[\]$/', '', (string) $input['name']); + $single = empty($input['multiple']); + + $options = $page->getSelectElements($input); + $first = TRUE; + $index = 0; + $out = array(); + 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) { + $out[$key] = (string) $option['value']; + } + else { + $out[$key . '[' . $index++ . ']'] = (string) $option['value']; + } + } + return ($single ? $out[$key] : $out); + } + break; + case 'file': + return NULL; + case 'radio': + case 'checkbox': + if (!isset($input['checked'])) { + return NULL; + } + // Deliberately no break. + default: + return $html_value; + } + } + + protected function processFields($form, $fields) { + + } + + public function request($method) { + if (!$this->isMethodSupported($method)) { + return FALSE; + } + + // TODO Support abitrary method. + } + + public function isMethodSupported($method) { + return in_array(strtoupper($method), $this->info['methods']); + } + + protected function setState($url, $headers, $content) { + $this->url = $url; + $this->headers = $headers; + $this->content = $content; + unset($this->page); + +// module_invoke_all('browser_request', self::$browser); // TODO decide on hooks + } + + /** + * Gets the current raw HTML of the last requested page. + * + * @return + * Raw HTML of last requested page. + */ + public function getContent() { + return $this->content; + } + + public function getPage() { + if (!isset($this->page)) { + $this->page = new BrowserPage($this->url, $this->headers, $this->content); + if (!$this->page->isValid()) { + return FALSE; + } + } + return $this->page; + } + + function __destruct() { + $this->wrapper->close(); + } +} Index: includes/browser/wrapper.inc =================================================================== RCS file: includes/browser/wrapper.inc diff -N includes/browser/wrapper.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ includes/browser/wrapper.inc 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,24 @@ + 'browser_test_print_get', + 'access arguments' => array('access content'), + ); + + $items['browser_test/print/post'] = array( + 'page callback' => 'drupal_get_form', + 'page arguments' => array('browser_test_print_post_form'), + 'access arguments' => array('access content'), + ); + + return $items; +} + +function browser_test_print_get() { + echo $_GET['foo']; + exit; +} + +function browser_test_print_post_form(&$form_state) { + $form = array(); + + $form['foo'] = array( + '#type' => 'textfield', + ); + $form['op'] = array( + '#type' => 'submit', + '#value' => t('Submit'), + ); + + return $form; +} + +function browser_test_print_post_form_submit($form, &$form_state) { + echo $form_state['values']['foo']; + exit; +} Index: includes/browser/curl.inc =================================================================== RCS file: includes/browser/curl.inc diff -N includes/browser/curl.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ includes/browser/curl.inc 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,153 @@ +handle)) { + $this->handle = curl_init(); + curl_setopt_array($this->handle, $this->getDefaultOptions()); + } + } + + /** + * Close the cURL handler and unset the handler. + */ + public function close() { + if (isset($this->handle)) { + curl_close($this->handle); + unset($this->handle); + } + } + + protected function getDefaultOptions() { + return array( + CURLOPT_COOKIEJAR => $this->cookieFile, + CURLOPT_FOLLOWLOCATION => TRUE, + CURLOPT_HEADERFUNCTION => array(&$this, 'headerCallback'), + CURLOPT_HTTPHEADER => $this->request_headers, + CURLOPT_RETURNTRANSFER => TRUE, + CURLOPT_SSL_VERIFYPEER => FALSE, + CURLOPT_SSL_VERIFYHOST => FALSE, + CURLOPT_URL => '/', + CURLOPT_USERAGENT => $this->request_headers['User-Agent'], + ); + } + + public function getRequestHeaders() { + return $this->request_headers; + } + + public function setRequestHeaders($headers = array()) { + $this->request_headers = $headers; + + // Update request headers if handle is open. + if (isset($this->handle)) { + curl_setopt($this->handle, CURLOPT_USERAGENT, $this->request_headers['User-Agent']); + curl_setopt($this->handle, CURLOPT_HTTPHEADER, $this->request_headers); + } + } + + public function get($url) { + $this->execute(array( + CURLOPT_HTTPGET => TRUE, + CURLOPT_URL => $url, + CURLOPT_NOBODY => FALSE, + )); + + return $this->buildRequest(); + } + + public function post($url, array $fields) { + // TODO Add upload handling code. + + $this->execute(array( + CURLOPT_POST => TRUE, + CURLOPT_URL => $url, + CURLOPT_POSTFIELDS => http_build_query($fields, NULL, '&'), + )); + + return $this->buildRequest(); + } + + public function request($method) { + // TODO CURLOPT_CUSTOMREQUEST + } + + protected function buildRequest() { + if ($this->content !== FALSE) { + return array( + 'url' => $this->url, + 'headers' => $this->headers, + 'content' => $this->content, + ); + } + return FALSE; + } + + /** + * Performs a cURL exec with the specified options after calling curlConnect(). + * + * @param $options + * Changes to the current cURL options. + */ + protected function execute($options) { + $this->open(); + + curl_setopt_array($this->handle, $options); + $this->content = curl_exec($this->handle); + $this->url = curl_getinfo($this->handle, CURLINFO_EFFECTIVE_URL); + // $this->headers should be filled by headerCallback. + } + + /** + * Reads headers and stores in $headers array. + * + * @param $curlHandler + * The cURL handler. + * @param $header + * An header. + * @return + * The string length of the header. (required by cURL) + */ + protected function headerCallback($handler, $header) { + $clean_header = trim($header); + if ($clean_header) { + $parts = explode(':', $clean_header, 2); + + // Ensure header line is valid. + if (count($parts) == 2) { + $this->headers[trim($parts[0])] = trim($parts[1]); + } + } + return strlen($header); + } +} Index: includes/browser/page.inc =================================================================== RCS file: includes/browser/page.inc diff -N includes/browser/page.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ includes/browser/page.inc 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,239 @@ +url = $url; + $this->headers = $headers; + $this->root = $this->load($content); + } + + /** + * Load contents into simplexml. + * + * @param $content + * Content to load. + * @return + * Root SimpleXML element or FALSE. + */ + protected function load($content) { + // Use DOM to load HTML soup, and hide warnings. + $dom = DOMDocument::loadHTML($content); + if ($dom) { + return simplexml_import_dom($dom); + } + return FALSE; + } + + /** + * Check if the content could be loaded. + * + * @return + * TRUE if content is loaded, FALSE if content failed to load. + */ + public function isValid() { + return ($this->root !== FALSE); + } + + /** + * Peform an xpath search on the contents of the page. The search is relative + * to the root element (HTML tag normally) of the page. + * + * @param $xpath + * The xpath string to use in the search. + * @return + * The return value of the xpath search. For details on the xpath string + * format and return values see the SimpleXML documentation. + * http://us.php.net/manual/function.simplexml-element-xpath.php + */ + public function xpath($xpath) { + return $this->root->xpath($xpath); + } + + /** + * Get all the form elements contained by the page. + * + * @return + * An array of form elements. + */ + public function getForms() { + return $this->xpath('//form'); + } + + /** + * Get all the input elements contained by the page, or nested within a form + * when specified. + * + * @param $form + * Searched for inputs contained by the form. + * @return + * Array of input elements. + */ + public function getInputs($form = NULL) { + if ($form) { + return $form->xpath('.//input|.//textarea|.//select'); + } + return $this->xpath('.//input|.//textarea|.//select'); + } + + public function getField() { + // TODO + } + + /** + * Get all the options contained by a select, including nested options. + * + * @param $select + * The select to get the options from. + * @return + * Associative array where the keys represent each option value and the + * value is the text contained within the option tag. For example: + * @code + * array( + * 'option1' => 'Option 1', + * 'option2' => 'Option 2', + * ) + * @endcode + */ + public function getSelectOptions(SimpleXMLElement $select) { + $elements = getSelectElements($select); + + $options = array(); + foreach ($elements as $element) { + $options[(string) $element['value']] = asText($element); + } + return $options; + } + + /** + * Get all selected options contained by a select, including nested options. + * + * @param $select + * The select to get the options from. + * @return + * Associative array of selected items in the format described by + * getSelectOptions(). + */ + public function getSelectedOptions(SimpleXMLElement $select) { + $elements = getSelectElements($select); + + $options = array(); + foreach ($elements as $element) { + if (isset($elements['selected'])) { + $options[(string) $element['value']] = asText($element); + } + } + return $options; + } + + /** + * Get all the options contained by a select, including nested options. + * + * @param $element + * The element to get the options from. + * @return + * An array of options contained by the select. + */ + public function getSelectElements(SimpleXMLElement $element) { + $options = array(); + + // Add all options items. + foreach ($element->option as $option) { + $options[] = $option; + } + + // Search option group children. + if (isset($element->optgroup)) { + foreach ($element->optgroup as $group) { + $options = array_merge($options, $this->getAllOptions($group)); + } + } + return $options; + } + + public function getAbsoluteUrl($path) { + $parts = @parse_url($path); + if (isset($parts['scheme'])) { + return $path; + } + + $base = $this->getBaseUrl(); + if ($path[0] == '/') { + // Lead / then use host as base. + $parts = parse_url($base); + $base = $parts['scheme'] . '://' . $parts['host']; + } + return $base . $path; + } + + public function getBaseUrl() { + // Check for base element. + $elements = $this->xpath('.//base'); + if ($elements) { + // More than one may be specified. + foreach ($elements as $element) { + if (isset($element['href'])) { + $base = (string) $element['href']; + break; + } + } + } + else { + $base = substr($this->url, strpos($this->url, '?')); + } + + if ($base[strlen($base) - 1] != '/') { + $base .= '/'; + } + return $base; + } + + /** + * Extract the text contained by the element. + * + * @param $element + * Element to extract text from. + * @return + * Extracted text. + */ + public function asText(SimpleXMLElement $element) { + return trim(html_entity_decode(strip_tags($element->asXML()))); + } +}