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 01:54:50 -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 01:54:51 -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,80 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Tests for the internal web browser.
+ */
+
+/**
+ * Test browser backend wrappers.
+ */
+class BrowserBackendTestCase extends DrupalWebTestCase {
+
+  public static function getInfo() {
+    return array(
+      'name' => 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() {
+    foreach (array('stream', 'curl') as $wrapper) {
+      $browser = Browser::getInstance($wrapper);
+
+      $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() {
+    $browser = Browser::getInstance();
+
+    $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 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Stream implementation of for the browser.
+ */
+
+class HttpWrapper_stream implements HttpWrapper {
+
+  protected $handle;
+
+  protected $request_headers = array();
+
+  protected $cookie = array();
+
+  protected $url;
+
+  protected $headers;
+
+  protected $content;
+
+  public function open() {
+    if (!isset($this->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 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Text web browser for Drupal.
+ */
+
+/*
+ * TODO
+ *
+ * Hooks for get, post events.
+ * HTTP authentication
+ * Decide on parsing which will allow for meta refresh suppose
+ * Deal with Drupal-Assertion callback stuff.
+ * Support PUT, DELETE, HEAD, and OPTIONS -chx
+ *   http://moggy.laceous.com/2008/08/01/custom-request-methods-and-php/
+ *   "RESTfully designed web-services use POST, GET, PUT, and DELETE"
+ * Error handling.
+ * Deal a from containing one button without a name, even no value?
+ * Page support proxy behavior.
+ *
+ * try out http_build_query() instead of current code
+ * parse header information, specific url in stream backend
+ * http://us.php.net/manual/en/context.http.php
+ */
+
+class Browser {
+
+  /**
+   * The DrupalBrowser instance.
+   *
+   * @var DrupalBrowser
+   */
+  protected static $browser = array();
+
+  protected $wrapper;
+
+  protected $info;
+
+  /**
+   * The URL of the current page.
+   *
+   * @var string
+   */
+  protected $url;
+
+  /**
+   * The headers of the page current page.
+   *
+   * @var Array
+   */
+  protected $headers;
+
+  /**
+   * The content of the page currently loaded in the internal browser.
+   *
+   * @var string
+   */
+  protected $content;
+
+  protected $page;
+
+  function __construct($wrapper, $info) {
+    // Include the required wrapper and page files.
+    require_once './includes/browser/wrapper.inc';
+    require_once './includes/browser/page.inc';
+    require_once $info['file']; // TODO better way?
+
+    $class = 'HttpWrapper_' . $wrapper;
+    $this->wrapper = new $class();
+    $this->info = $info;
+
+    $this->setUserAgent('Drupal (+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 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Text web browser for Drupal.
+ */
+
+interface HttpWrapper {
+
+  public function open();
+
+  public function close();
+
+  public function getRequestHeaders();
+
+  public function setRequestHeaders($headers = array());
+
+  public function get($url);
+
+  public function post($url, array $fields);
+
+  public function request($method);
+}
Index: modules/simpletest/tests/browser_test.info
===================================================================
RCS file: modules/simpletest/tests/browser_test.info
diff -N modules/simpletest/tests/browser_test.info
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/simpletest/tests/browser_test.info	1 Jan 1970 00:00:00 -0000
@@ -0,0 +1,8 @@
+; $Id$
+name = Browser test
+description = Provide various pages for testing the browser.
+package = Testing
+version = VERSION
+core = 7.x
+files[] = browser_test.module
+;hidden = TRUE
Index: modules/simpletest/tests/browser_test.module
===================================================================
RCS file: modules/simpletest/tests/browser_test.module
diff -N modules/simpletest/tests/browser_test.module
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ modules/simpletest/tests/browser_test.module	1 Jan 1970 00:00:00 -0000
@@ -0,0 +1,53 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Provide various pages for testing the internal browser.
+ */
+
+/**
+ * Implement hook_menu().
+ */
+function browser_test_menu() {
+  $items = array();
+
+  $items['browser_test/print/get'] = array(
+    'title' => 'Print - GET',
+    'page callback' => 'browser_test_print_get',
+    'access arguments' => array('access content'),
+  );
+
+  $items['browser_test/print/post'] = array(
+    'title' => 'Print - POST',
+    '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 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Curl implementation of for the browser.
+ */
+
+class HttpWrapper_curl implements HttpWrapper {
+
+  /**
+   * The handle of the current cURL connection.
+   *
+   * @var resource
+   */
+  protected $handle;
+
+  /**
+   * The current cookie file used by cURL.
+   *
+   * 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 $cookieFile = NULL;
+
+  protected $request_headers = array();
+
+  protected $url;
+
+  protected $headers;
+
+  protected $content;
+
+  public function open() {
+    if (!isset($this->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 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * BrowserPage class used with Browser.
+ */
+
+/**
+ * Represents a page of content that has been fetched by the Browser. The class
+ * provides a number of convenience methods that relate to page content.
+ */
+class BrowserPage {
+
+  /**
+   * The URL of the current page.
+   *
+   * @var string
+   */
+  protected $url;
+
+  /**
+   * The headers of the page current page.
+   *
+   * @var Array
+   */
+  protected $headers;
+
+  /**
+   * The root element of the loaded content.
+   *
+   * @var SimpleXMLElement
+   */
+  protected $root;
+
+  /**
+   * Load content into page.
+   *
+   * @param $content
+   *   Content to load.
+   */
+  public function BrowserPage($url, $headers, $content) {
+    $this->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())));
+  }
+}
