diff --git a/CHANGES.rst b/CHANGES.rst
index c0afa03..971d377 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,126 @@
+1.4.4 (unreleased)
+------------------
+
+- Add nextUntil method
+
+
+1.4.3 (2020-11-21)
+------------------
+
+- No longer use a universal wheel
+
+
+1.4.2 (2020-11-21)
+------------------
+
+- Fix exception raised when calling `PyQuery("<textarea></textarea>").text()`
+
+- python2 is no longer supported
+
+1.4.1 (2019-10-26)
+------------------
+
+- This is the latest release with py2 support
+
+- Remove py33, py34 support
+
+- web scraping improvements: default timeout and session support
+
+- Add API methods to serialize form-related elements according to spec
+
+- Include HTML markup when querying textarea text/value
+
+
+1.4.0 (2018-01-11)
+------------------
+
+- Refactoring of `.text()` to match firefox behavior.
+
+
+1.3.0 (2017-10-21)
+------------------
+
+- Remove some unmaintained modules: ``pyquery.ajax`` and ``pyquery.rules``
+
+- Code cleanup. No longer use ugly hacks required by python2.6/python3.2.
+
+- Run tests with python3.6 on CI
+
+- Add a ``method`` argument to ``.outer_html()``
+
+
+1.2.17 (2016-10-14)
+-------------------
+
+- ``PyQuery('<input value="">').val()`` is ``''``
+- ``PyQuery('<input>').val()`` is ``''``
+
+
+1.2.16 (2016-10-14)
+-------------------
+
+- ``.attr('value', '')`` no longer removes the ``value`` attribute
+
+- ``<input type="checkbox">`` without ``value="..."`` have a ``.val()`` of
+  ``'on'``
+
+- ``<input type="radio">`` without ``value="..."`` have a ``.val()`` of
+  ``'on'``
+
+- ``<select>`` without ``<option selected>`` have the value of their first
+  ``<option>`` (or ``None`` if there are no options)
+
+
+1.2.15 (2016-10-11)
+-------------------
+
+- .val() should never raise
+
+- drop py26 support
+
+- improve .extend() by returning self
+
+
+1.2.14 (2016-10-10)
+-------------------
+
+- fix val() for <textarea> and <select>, to match jQuery behavior
+
+
+1.2.13 (2016-04-12)
+-------------------
+
+- Note explicit support for Python 3.5
+
+1.2.12 (2016-04-12)
+-------------------
+
+- make_links_absolute now take care of whitespaces
+
+- added pseudo selector :has()
+
+- add cookies arguments as allowed arguments for requests
+
+
+1.2.11 (2016-02-02)
+-------------------
+
+- Preserve namespaces attribute on PyQuery copies.
+
+- Do not raise an error when the http response code is 2XX
+
+1.2.10 (2016-01-05)
+-------------------
+
+- Fixed #118: implemented usage ``lxml.etree.tostring`` within ``outer_html`` method
+
+- Fixed #117: Raise HTTP Error if HTTP status code is not equal to 200
+
+- Fixed #112: make_links_absolute does not apply to form actions
+
+- Fixed #98: contains act like jQuery
+
+
 1.2.9 (2014-08-22)
 ------------------
 
@@ -40,110 +163,109 @@
 1.2.6 (2013-10-11)
 ------------------
 
-README_fixt.py was not include in the release. Fix #54.
+- README_fixt.py was not include in the release. Fix #54.
 
 
 1.2.5 (2013-10-10)
 ------------------
 
-cssselect compat. See https://github.com/SimonSapin/cssselect/pull/22
+- cssselect compat. See https://github.com/SimonSapin/cssselect/pull/22
 
-tests improvments. no longer require a eth connection.
+- tests improvments. no longer require a eth connection.
 
-fix #55
+- fix #55
 
 1.2.4
 -----
 
-Moved to github. So a few files are renamed from .txt to .rst
+- Moved to github. So a few files are renamed from .txt to .rst
 
-Added .xhtml_to_html() and .remove_namespaces()
+- Added .xhtml_to_html() and .remove_namespaces()
 
-Use requests to fetch urls (if available)
+- Use requests to fetch urls (if available)
 
-Use restkit's proxy instead of Paste (which will die with py3)
+- Use restkit's proxy instead of Paste (which will die with py3)
 
-Allow to open https urls
+- Allow to open https urls
 
-python2.5 is no longer supported (may work, but tests are broken)
+- python2.5 is no longer supported (may work, but tests are broken)
 
 1.2.3
 -----
 
-Allow to pass this in .filter() callback
+- Allow to pass this in .filter() callback
 
-Add .contents() .items()
+- Add .contents() .items()
 
-Add tox.ini
+- Add tox.ini
 
-Bug fixes: fix #35 #55 #64 #66
+- Bug fixes: fix #35 #55 #64 #66
 
 1.2.2
 -----
 
-Fix cssselectpatch to match the newer implementation of cssselect. Fixes issue #62, #52 and #59 (Haoyu Bai)
+- Fix cssselectpatch to match the newer implementation of cssselect. Fixes issue #62, #52 and #59 (Haoyu Bai)
 
-Fix issue #37 (Caleb Burns)
+- Fix issue #37 (Caleb Burns)
 
 1.2.1
 -----
 
-Allow to use a custom css translator.
+- Allow to use a custom css translator.
 
-Fix issue 44: case problem with xml documents
+- Fix issue 44: case problem with xml documents
 
 1.2
 ---
 
-PyQuery now use `cssselect <http://pypi.python.org/pypi/cssselect>`_. See issue
-43.
+- PyQuery now uses `cssselect <http://pypi.python.org/pypi/cssselect>`_. See issue 43.
 
-Fix issue 40: forward .html() extra arguments to ``lxml.etree.tostring``
+- Fix issue 40: forward .html() extra arguments to ``lxml.etree.tostring``
 
 1.1.1
 -----
 
-Minor release. Include test file so you can run tests from the tarball.
+- Minor release. Include test file so you can run tests from the tarball.
 
 
 1.1
 ---
 
-fix issues 30, 31, 32 - py3 improvements / webob 1.2+ support
+- fix issues 30, 31, 32 - py3 improvements / webob 1.2+ support
 
 
 1.0
 ---
 
-fix issues 24
+- fix issues 24
 
 0.7
 ---
 
-Python 3 compatible
+- Python 3 compatible
 
-Add __unicode__ method
+- Add __unicode__ method
 
-Add root and encoding attribute
+- Add root and encoding attribute
 
-fix issues 19, 20, 22, 23 
+- fix issues 19, 20, 22, 23 
 
 0.6.1
 ------
 
-Move README.txt at package root
+- Move README.txt at package root
 
-Add CHANGES.txt and add it to long_description
+- Add CHANGES.txt and add it to long_description
 
 0.6
 ----
 
-Added PyQuery.outerHtml
+- Added PyQuery.outerHtml
 
-Added PyQuery.fn
+- Added PyQuery.fn
 
-Added PyQuery.map
+- Added PyQuery.map
 
-Change PyQuery.each behavior to reflect jQuery api
+- Change PyQuery.each behavior to reflect jQuery api
 
 
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..ed34f8c
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,29 @@
+Copyright (C) 2008 - Olivier Lauzanne <olauzanne@gmail.com>
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+  1. Redistributions of source code must retain the above copyright
+     notice, this list of conditions and the following disclaimer.
+
+  2. Redistributions in binary form must reproduce the above copyright
+     notice, this list of conditions and the following disclaimer in
+     the documentation and/or other materials provided with the
+     distribution.
+
+  3. Neither the name of Infrae nor the names of its contributors may
+     be used to endorse or promote products derived from this software
+     without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INFRAE OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/MANIFEST.in b/MANIFEST.in
index 861cd34..7faa025 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -2,6 +2,8 @@ graft docs
 prune docs/_build
 graft pyquery
 graft tests
+include *.py
+include *.txt
 include *_fixt.py *.rst *.cfg *.ini
 global-exclude *.pyc
 global-exclude __pycache__
diff --git a/PKG-INFO b/PKG-INFO
index 3f6525d..d5859c8 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,15 +1,21 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: pyquery
-Version: 1.2.9
+Version: 1.4.4.dev0
 Summary: A jquery-like library for python
 Home-page: https://github.com/gawel/pyquery
-Author: Gael Pasgrimaud
-Author-email: gael@gawel.org
+Author: Olivier Lauzanne
+Author-email: olauzanne@gmail.com
+Maintainer: Gael Pasgrimaud
+Maintainer-email: gael@gawel.org
 License: BSD
 Description: 
         pyquery: a jquery-like library for python
         =========================================
         
+        .. image:: https://travis-ci.org/gawel/pyquery.svg
+           :alt: Build Status
+           :target: https://travis-ci.org/gawel/pyquery
+        
         pyquery allows you to make jquery queries on xml documents.
         The API is as much as possible the similar to jquery. pyquery uses lxml for fast
         xml and html manipulation.
@@ -20,7 +26,7 @@ Description:
         
         The `project`_ is being actively developped on a git repository on Github. I
         have the policy of giving push access to anyone who wants it and then to review
-        what he does. So if you want to contribute just email me.
+        what they do. So if you want to contribute just email me.
         
         Please report bugs on the `github
         <https://github.com/gawel/pyquery/issues>`_ issue
@@ -29,6 +35,18 @@ Description:
         .. _deliverance: http://www.gawel.org/weblog/en/2008/12/skinning-with-pyquery-and-deliverance
         .. _project: https://github.com/gawel/pyquery/
         
+        I've spent hours maintaining this software, with love.
+        Please consider tiping if you like it:
+        
+        BTC: 1PruQAwByDndFZ7vTeJhyWefAghaZx9RZg
+        
+        ETH: 0xb6418036d8E06c60C4D91c17d72Df6e1e5b15CE6
+        
+        LTC: LY6CdZcDbxnBX9GFBJ45TqVj8NykBBqsmT
+        
+        ..
+           >>> (urlopen, your_url, path_to_html_file) = getfixture('readme_fixt')
+        
         Quickstart
         ==========
         
@@ -73,6 +91,129 @@ Description:
         News
         ====
         
+        1.4.4 (unreleased)
+        ------------------
+        
+        - Add nextUntil method
+        
+        
+        1.4.3 (2020-11-21)
+        ------------------
+        
+        - No longer use a universal wheel
+        
+        
+        1.4.2 (2020-11-21)
+        ------------------
+        
+        - Fix exception raised when calling `PyQuery("<textarea></textarea>").text()`
+        
+        - python2 is no longer supported
+        
+        1.4.1 (2019-10-26)
+        ------------------
+        
+        - This is the latest release with py2 support
+        
+        - Remove py33, py34 support
+        
+        - web scraping improvements: default timeout and session support
+        
+        - Add API methods to serialize form-related elements according to spec
+        
+        - Include HTML markup when querying textarea text/value
+        
+        
+        1.4.0 (2018-01-11)
+        ------------------
+        
+        - Refactoring of `.text()` to match firefox behavior.
+        
+        
+        1.3.0 (2017-10-21)
+        ------------------
+        
+        - Remove some unmaintained modules: ``pyquery.ajax`` and ``pyquery.rules``
+        
+        - Code cleanup. No longer use ugly hacks required by python2.6/python3.2.
+        
+        - Run tests with python3.6 on CI
+        
+        - Add a ``method`` argument to ``.outer_html()``
+        
+        
+        1.2.17 (2016-10-14)
+        -------------------
+        
+        - ``PyQuery('<input value="">').val()`` is ``''``
+        - ``PyQuery('<input>').val()`` is ``''``
+        
+        
+        1.2.16 (2016-10-14)
+        -------------------
+        
+        - ``.attr('value', '')`` no longer removes the ``value`` attribute
+        
+        - ``<input type="checkbox">`` without ``value="..."`` have a ``.val()`` of
+          ``'on'``
+        
+        - ``<input type="radio">`` without ``value="..."`` have a ``.val()`` of
+          ``'on'``
+        
+        - ``<select>`` without ``<option selected>`` have the value of their first
+          ``<option>`` (or ``None`` if there are no options)
+        
+        
+        1.2.15 (2016-10-11)
+        -------------------
+        
+        - .val() should never raise
+        
+        - drop py26 support
+        
+        - improve .extend() by returning self
+        
+        
+        1.2.14 (2016-10-10)
+        -------------------
+        
+        - fix val() for <textarea> and <select>, to match jQuery behavior
+        
+        
+        1.2.13 (2016-04-12)
+        -------------------
+        
+        - Note explicit support for Python 3.5
+        
+        1.2.12 (2016-04-12)
+        -------------------
+        
+        - make_links_absolute now take care of whitespaces
+        
+        - added pseudo selector :has()
+        
+        - add cookies arguments as allowed arguments for requests
+        
+        
+        1.2.11 (2016-02-02)
+        -------------------
+        
+        - Preserve namespaces attribute on PyQuery copies.
+        
+        - Do not raise an error when the http response code is 2XX
+        
+        1.2.10 (2016-01-05)
+        -------------------
+        
+        - Fixed #118: implemented usage ``lxml.etree.tostring`` within ``outer_html`` method
+        
+        - Fixed #117: Raise HTTP Error if HTTP status code is not equal to 200
+        
+        - Fixed #112: make_links_absolute does not apply to form actions
+        
+        - Fixed #98: contains act like jQuery
+        
+        
         1.2.9 (2014-08-22)
         ------------------
         
@@ -115,111 +256,110 @@ Description:
         1.2.6 (2013-10-11)
         ------------------
         
-        README_fixt.py was not include in the release. Fix #54.
+        - README_fixt.py was not include in the release. Fix #54.
         
         
         1.2.5 (2013-10-10)
         ------------------
         
-        cssselect compat. See https://github.com/SimonSapin/cssselect/pull/22
+        - cssselect compat. See https://github.com/SimonSapin/cssselect/pull/22
         
-        tests improvments. no longer require a eth connection.
+        - tests improvments. no longer require a eth connection.
         
-        fix #55
+        - fix #55
         
         1.2.4
         -----
         
-        Moved to github. So a few files are renamed from .txt to .rst
+        - Moved to github. So a few files are renamed from .txt to .rst
         
-        Added .xhtml_to_html() and .remove_namespaces()
+        - Added .xhtml_to_html() and .remove_namespaces()
         
-        Use requests to fetch urls (if available)
+        - Use requests to fetch urls (if available)
         
-        Use restkit's proxy instead of Paste (which will die with py3)
+        - Use restkit's proxy instead of Paste (which will die with py3)
         
-        Allow to open https urls
+        - Allow to open https urls
         
-        python2.5 is no longer supported (may work, but tests are broken)
+        - python2.5 is no longer supported (may work, but tests are broken)
         
         1.2.3
         -----
         
-        Allow to pass this in .filter() callback
+        - Allow to pass this in .filter() callback
         
-        Add .contents() .items()
+        - Add .contents() .items()
         
-        Add tox.ini
+        - Add tox.ini
         
-        Bug fixes: fix #35 #55 #64 #66
+        - Bug fixes: fix #35 #55 #64 #66
         
         1.2.2
         -----
         
-        Fix cssselectpatch to match the newer implementation of cssselect. Fixes issue #62, #52 and #59 (Haoyu Bai)
+        - Fix cssselectpatch to match the newer implementation of cssselect. Fixes issue #62, #52 and #59 (Haoyu Bai)
         
-        Fix issue #37 (Caleb Burns)
+        - Fix issue #37 (Caleb Burns)
         
         1.2.1
         -----
         
-        Allow to use a custom css translator.
+        - Allow to use a custom css translator.
         
-        Fix issue 44: case problem with xml documents
+        - Fix issue 44: case problem with xml documents
         
         1.2
         ---
         
-        PyQuery now use `cssselect <http://pypi.python.org/pypi/cssselect>`_. See issue
-        43.
+        - PyQuery now uses `cssselect <http://pypi.python.org/pypi/cssselect>`_. See issue 43.
         
-        Fix issue 40: forward .html() extra arguments to ``lxml.etree.tostring``
+        - Fix issue 40: forward .html() extra arguments to ``lxml.etree.tostring``
         
         1.1.1
         -----
         
-        Minor release. Include test file so you can run tests from the tarball.
+        - Minor release. Include test file so you can run tests from the tarball.
         
         
         1.1
         ---
         
-        fix issues 30, 31, 32 - py3 improvements / webob 1.2+ support
+        - fix issues 30, 31, 32 - py3 improvements / webob 1.2+ support
         
         
         1.0
         ---
         
-        fix issues 24
+        - fix issues 24
         
         0.7
         ---
         
-        Python 3 compatible
+        - Python 3 compatible
         
-        Add __unicode__ method
+        - Add __unicode__ method
         
-        Add root and encoding attribute
+        - Add root and encoding attribute
         
-        fix issues 19, 20, 22, 23 
+        - fix issues 19, 20, 22, 23 
         
         0.6.1
         ------
         
-        Move README.txt at package root
+        - Move README.txt at package root
         
-        Add CHANGES.txt and add it to long_description
+        - Add CHANGES.txt and add it to long_description
         
         0.6
         ----
         
-        Added PyQuery.outerHtml
+        - Added PyQuery.outerHtml
         
-        Added PyQuery.fn
+        - Added PyQuery.fn
         
-        Added PyQuery.map
+        - Added PyQuery.map
         
-        Change PyQuery.each behavior to reflect jQuery api
+        - Change PyQuery.each behavior to reflect jQuery api
         
         
         
@@ -229,9 +369,8 @@ Keywords: jquery html xml scraping
 Platform: UNKNOWN
 Classifier: Intended Audience :: Developers
 Classifier: Development Status :: 5 - Production/Stable
-Classifier: Programming Language :: Python :: 2
-Classifier: Programming Language :: Python :: 2.6
-Classifier: Programming Language :: Python :: 2.7
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.3
-Classifier: Programming Language :: Python :: 3.4
+Classifier: Programming Language :: Python :: 3.5
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Provides-Extra: test
diff --git a/README.rst b/README.rst
index a3e5238..4fe6829 100644
--- a/README.rst
+++ b/README.rst
@@ -1,6 +1,10 @@
 pyquery: a jquery-like library for python
 =========================================
 
+.. image:: https://travis-ci.org/gawel/pyquery.svg
+   :alt: Build Status
+   :target: https://travis-ci.org/gawel/pyquery
+
 pyquery allows you to make jquery queries on xml documents.
 The API is as much as possible the similar to jquery. pyquery uses lxml for fast
 xml and html manipulation.
@@ -11,7 +15,7 @@ told myself "Hey let's make jquery in python". This is the result.
 
 The `project`_ is being actively developped on a git repository on Github. I
 have the policy of giving push access to anyone who wants it and then to review
-what he does. So if you want to contribute just email me.
+what they do. So if you want to contribute just email me.
 
 Please report bugs on the `github
 <https://github.com/gawel/pyquery/issues>`_ issue
@@ -20,6 +24,18 @@ tracker.
 .. _deliverance: http://www.gawel.org/weblog/en/2008/12/skinning-with-pyquery-and-deliverance
 .. _project: https://github.com/gawel/pyquery/
 
+I've spent hours maintaining this software, with love.
+Please consider tiping if you like it:
+
+BTC: 1PruQAwByDndFZ7vTeJhyWefAghaZx9RZg
+
+ETH: 0xb6418036d8E06c60C4D91c17d72Df6e1e5b15CE6
+
+LTC: LY6CdZcDbxnBX9GFBJ45TqVj8NykBBqsmT
+
+..
+   >>> (urlopen, your_url, path_to_html_file) = getfixture('readme_fixt')
+
 Quickstart
 ==========
 
diff --git a/buildout.cfg b/buildout.cfg
deleted file mode 100644
index 1c1ecea..0000000
--- a/buildout.cfg
+++ /dev/null
@@ -1,37 +0,0 @@
-[buildout]
-newest = false
-parts = py2 docs
-develop = .
-
-[py3]
-recipe = zc.recipe.egg
-eggs =
-    cssselect>0.7.9
-    WebOb>1.1.9
-    WebTest
-    pyquery
-    nose
-    coverage
-
-[py2]
-recipe = zc.recipe.egg
-eggs =
-    ${py3:eggs}
-    unittest2
-    BeautifulSoup
-    restkit
-
-
-[docs]
-recipe = zc.recipe.egg
-eggs =
-    ${py2:eggs}
-    Pygments
-    Sphinx
-    sphinx-pypi-upload
-interpreter = py
-scripts =
-    sphinx-build
-
-[tox]
-recipe = gp.recipe.tox
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..466184f
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,18 @@
+import os
+import pytest
+from webtest import http
+from webtest.debugapp import debug_app
+from urllib.request import urlopen
+
+
+@pytest.fixture
+def readme_fixt():
+    server = http.StopableWSGIServer.create(debug_app)
+    server.wait()
+    path_to_html_file = os.path.join('tests', 'test.html')
+    yield (
+        urlopen,
+        server.application_url,
+        path_to_html_file,
+    )
+    server.shutdown()
diff --git a/debian/changelog b/debian/changelog
index 8f3a429..5b39850 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,4 +1,4 @@
-pyquery (1.2.9-5) UNRELEASED; urgency=medium
+pyquery (1.4.3+git20210309.1.5e32510-1) UNRELEASED; urgency=medium
 
   [ Debian Janitor ]
   * Bump debhelper from old 9 to 12.
@@ -11,7 +11,7 @@ pyquery (1.2.9-5) UNRELEASED; urgency=medium
   * d/control: Update Vcs-* fields with new Debian Python Team Salsa
     layout.
 
- -- Debian Janitor <janitor@jelmer.uk>  Mon, 13 Apr 2020 21:25:33 +0000
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 05 Apr 2021 08:33:24 -0000
 
 pyquery (1.2.9-4) unstable; urgency=medium
 
diff --git a/docs/ajax.rst b/docs/ajax.rst
deleted file mode 100644
index 3d57c7a..0000000
--- a/docs/ajax.rst
+++ /dev/null
@@ -1,57 +0,0 @@
-=============================================
-:mod:`pyquery.ajax` -- PyQuery AJAX extension
-=============================================
-
-.. automodule:: pyquery.ajax
-
-
-.. fake imports
-
-    >>> from pyquery.ajax import PyQuery as pq
-
-You can query some wsgi app if `WebOb`_ is installed (it's not a pyquery
-dependencie). IN this example the test app returns a simple input at `/` and a
-submit button at `/submit`::
-
-    >>> d = pq('<form></form>', app=input_app)
-    >>> d.append(d.get('/'))
-    [<form>]
-    >>> print(d)
-    <form><input name="youyou" type="text" value=""/></form>
-
-The app is also available in new nodes::
-
-    >>> d.get('/').app is d.app is d('form').app
-    True
-
-You can also request another path::
-
-    >>> d.append(d.get('/submit'))
-    [<form>]
-    >>> print(d)
-    <form><input name="youyou" type="text" value=""/><input type="submit" value="OK"/></form>
-
-If `restkit`_ is installed, you are able to get url directly with a `HostProxy`_ app::
-
-    >>> a = d.get(your_url)
-    >>> a
-    [<html>]
-
-You can retrieve the app response::
-
-    >>> print(a.response.status)
-    200 OK
-
-The response attribute is a `WebOb`_ `Response`_
-
-.. _webob: http://pythonpaste.org/webob/
-.. _response: http://pythonpaste.org/webob/#response
-.. _restkit: http://benoitc.github.com/restkit/
-.. _hostproxy: http://benoitc.github.com/restkit/wsgi_proxy.html
-
-Api
----
-
-.. autoclass:: PyQuery
-   :members:
-
diff --git a/docs/ajax_fixt.py b/docs/ajax_fixt.py
deleted file mode 100644
index c2f639d..0000000
--- a/docs/ajax_fixt.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# -*- coding: utf-8 -*-
-import os
-import sys
-sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
-from webtest import http
-from doctest import SKIP
-from tests.apps import input_app
-
-PY3 = sys.version_info >= (3,)
-
-
-def setup_test(test):
-    for example in test.examples:
-        # urlopen as moved in py3
-        if PY3:
-            example.options.setdefault(SKIP, 1)
-    if not PY3:
-        server = http.StopableWSGIServer.create(input_app)
-        server.wait()
-        path_to_html_file = os.path.join('tests', 'test.html')
-        test.globs.update(
-            input_app=input_app,
-            server=server,
-            your_url=server.application_url.rstrip('/') + '/html',
-            path_to_html_file=path_to_html_file,
-        )
-setup_test.__test__ = False
-
-
-def teardown_test(test):
-    if 'server' in test.globs:
-        test.globs['server'].shutdown()
-teardown_test.__test__ = False
diff --git a/docs/attributes.rst b/docs/attributes.rst
index 5020bf8..36f1ab5 100644
--- a/docs/attributes.rst
+++ b/docs/attributes.rst
@@ -4,6 +4,14 @@ Attributes
 ..
     >>> from pyquery import PyQuery as pq
 
+Using attribute to select specific tag
+In attribute selectors, the value should be a valid CSS identifier or quoted as string::
+
+    >>> d = pq("<option value='1'><option value='2'>")
+    >>> d('option[value="1"]')
+    [<option>]
+
+
 You can play with the attributes with the jquery API::
 
     >>> p = pq('<p id="hello" class="hello"></p>')('p')
diff --git a/docs/conf.py b/docs/conf.py
index 116711e..eb10d24 100755
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -41,16 +41,16 @@ master_doc = 'index'
 
 # General information about the project.
 project = u'pyquery'
-copyright = u'2012, Olivier Lauzanne'
+copyright = u'2012-2017, Olivier Lauzanne'
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 #
 # The short X.Y version.
-version = '0.3'
+version = '1.3.x'
 # The full version, including alpha/beta/rc tags.
-release = '0.3'
+release = '1.3.x'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
@@ -254,27 +254,30 @@ if path.isfile(setup):
             break
 del pkg_dir, setup, path
 
-from pyquery.cssselectpatch import JQueryTranslator
-
-with open('pseudo_classes.rst', 'w') as fd:
-    fd.write('=========================\n')
-    fd.write('Using pseudo classes\n')
-    fd.write('=========================\n')
-    for k in sorted(dir(JQueryTranslator)):
-        if k.startswith('xpath_'):
-            attr = getattr(JQueryTranslator, k)
-            doc = getattr(attr, '__doc__', '') or ''
-            doc = doc.strip()
-            if doc.startswith('Common implementation'):
-                continue
-            k = k[6:]
-            if '_' not in k or not doc:
-                continue
-            k, t = k.split('_', 1)
-            if '_' in t:
-                continue
-            if t == 'function':
-                k += '()'
-            fd.write('\n\n:%s\n' % k)
-            fd.write('==================\n\n')
-            fd.write(doc.strip('..').replace('        ', '    '))
+try:
+    from pyquery.cssselectpatch import JQueryTranslator
+except ImportError:
+    pass
+else:
+    with open('pseudo_classes.rst', 'w') as fd:
+        fd.write('=========================\n')
+        fd.write('Using pseudo classes\n')
+        fd.write('=========================\n')
+        for k in sorted(dir(JQueryTranslator)):
+            if k.startswith('xpath_'):
+                attr = getattr(JQueryTranslator, k)
+                doc = getattr(attr, '__doc__', '') or ''
+                doc = doc.strip()
+                if doc.startswith('Common implementation'):
+                    continue
+                k = k[6:]
+                if '_' not in k or not doc:
+                    continue
+                k, t = k.split('_', 1)
+                if '_' in t:
+                    continue
+                if t == 'function':
+                    k += '()'
+                fd.write('\n\n:%s\n' % k)
+                fd.write('==================\n\n')
+                fd.write(doc.strip('..').replace('        ', '    '))
diff --git a/docs/conftest.py b/docs/conftest.py
new file mode 100644
index 0000000..62adc68
--- /dev/null
+++ b/docs/conftest.py
@@ -0,0 +1,23 @@
+import os
+import sys
+import pytest
+from webtest import http
+from webtest.debugapp import debug_app
+
+
+@pytest.fixture
+def scrap_url():
+    sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+    from tests.apps import input_app
+    server = http.StopableWSGIServer.create(input_app)
+    server.wait()
+    yield server.application_url.rstrip('/') + '/html'
+    server.shutdown()
+
+
+@pytest.fixture
+def tips_url():
+    server = http.StopableWSGIServer.create(debug_app)
+    server.wait()
+    yield server.application_url.rstrip('/') + '/form.html'
+    server.shutdown()
diff --git a/docs/index.rst b/docs/index.rst
index 65de0e8..87d1ace 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -13,7 +13,6 @@ Full documentation
    traversing
    api
    scrap
-   ajax
    tips
    testing
    future
diff --git a/docs/manipulating.rst b/docs/manipulating.rst
index 22d8353..8b4943c 100644
--- a/docs/manipulating.rst
+++ b/docs/manipulating.rst
@@ -71,4 +71,11 @@ You can generate html stuff::
     >>> print(pq('<div>Yeah !</div>').addClass('myclass') + pq('<b>cool</b>'))
     <div class="myclass">Yeah !</div><b>cool</b>
 
+Remove all namespaces::
+
+    >>> d = pq('<foo xmlns="http://example.com/foo"></foo>')
+    >>> d
+    [<{http://example.com/foo}foo>]
+    >>> d.remove_namespaces()
+    [<foo>]
 
diff --git a/docs/pseudo_classes.rst b/docs/pseudo_classes.rst
index a8e07e5..3e117fd 100644
--- a/docs/pseudo_classes.rst
+++ b/docs/pseudo_classes.rst
@@ -52,7 +52,7 @@ Matches all elements that contain the given text
 
         >>> from pyquery import PyQuery
         >>> d = PyQuery('<div><h1/><h1 class="title">title</h1></div>')
-        >>> d(':contains("title")')
+        >>> d('h1:contains("title")')
         [<h1.title>]
 
     
@@ -82,7 +82,7 @@ Match all elements that do not contain other elements::
         >>> from pyquery import PyQuery
         >>> d = PyQuery('<div><h1><span>title</span></h1><h2/></div>')
         >>> d(':empty')
-        [<span>, <h2>]
+        [<h2>]
 
     
 
@@ -160,6 +160,25 @@ Matches all elements with an index over the given one::
 
     
 
+:has()
+==================
+
+Matches elements which contain at least one element that matches
+    the specified selector. https://api.jquery.com/has-selector/
+
+        >>> from pyquery import PyQuery
+        >>> d = PyQuery('<div class="foo"><div class="bar"></div></div>')
+        >>> d('.foo:has(".baz")')
+        []
+        >>> d('.foo:has(".foo")')
+        []
+        >>> d('.foo:has(".bar")')
+        [<div.foo>]
+        >>> d('.foo:has(div)')
+        [<div.foo>]
+
+    
+
 :header
 ==================
 
@@ -269,6 +288,14 @@ Matches all password input elements::
 
     
 
+:pseudo
+==================
+
+Translate a pseudo-element.
+
+    Defaults to not supporting pseudo-elements at all,
+    but can be overridden by sub-classes
+
 :radio
 ==================
 
diff --git a/docs/scrap.rst b/docs/scrap.rst
index c4eee85..86b0b6d 100644
--- a/docs/scrap.rst
+++ b/docs/scrap.rst
@@ -2,7 +2,8 @@ Scraping
 =========
 
 ..
-  >>> from pyquery.ajax import PyQuery as pq
+  >>> from pyquery import PyQuery as pq
+  >>> your_url = getfixture('scrap_url')
 
 PyQuery is able to load an html document from a url::
 
@@ -19,4 +20,15 @@ If `requests`_ is installed then it will use it. This allow you to use most of `
   >>> pq(your_url, {'q': 'foo'}, method='post', verify=True)
   [<html>]
 
+
+Timeout
+-------
+
+The default timeout is 60 seconds, you can change it by setting the timeout parameter which is forwarded to the underlying urllib or requests library.
+
+Session
+-------
+
+When using the requests library you can instantiate a Session object which keeps state between http calls (for example - to keep cookies). You can set the session parameter to use this session object.
+
 .. _requests: http://docs.python-requests.org/en/latest/
diff --git a/docs/scrap_fixt.py b/docs/scrap_fixt.py
deleted file mode 100644
index ec339e3..0000000
--- a/docs/scrap_fixt.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# -*- coding: utf-8 -*-
-import os
-import sys
-sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
-from webtest import http
-from tests.apps import input_app
-
-
-def setup_test(test):
-    server = http.StopableWSGIServer.create(input_app)
-    server.wait()
-    test.globs.update(
-        server=server,
-        your_url=server.application_url.rstrip('/') + '/html',
-    )
-setup_test.__test__ = False
-
-
-def teardown_test(test):
-    test.globs['server'].shutdown()
-teardown_test.__test__ = False
diff --git a/docs/tips.rst b/docs/tips.rst
index 6c5db85..87625bf 100644
--- a/docs/tips.rst
+++ b/docs/tips.rst
@@ -3,11 +3,12 @@ Tips
 
 ..
     >>> from pyquery import PyQuery as pq
+    >>> your_url = getfixture('tips_url')
 
 Making links absolute
 ---------------------
 
-You can make links absolute which can be usefull for screen scrapping::
+You can make links absolute which can be useful for screen scrapping::
 
     >>> d = pq(url=your_url, parser='html')
     >>> d('form').attr('action')
diff --git a/docs/tips_fixt.py b/docs/tips_fixt.py
deleted file mode 100644
index 32bcd67..0000000
--- a/docs/tips_fixt.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# -*- coding: utf-8 -*-
-import os
-from webtest import http
-from webtest.debugapp import debug_app
-
-
-def setup_test(test):
-    server = http.StopableWSGIServer.create(debug_app)
-    server.wait()
-    path_to_html_file = os.path.join('tests', 'test.html')
-    test.globs.update(
-        server=server,
-        your_url=server.application_url.rstrip('/') + '/form.html',
-        path_to_html_file=path_to_html_file,
-    )
-setup_test.__test__ = False
-
-
-def teardown_test(test):
-    test.globs['server'].shutdown()
-teardown_test.__test__ = False
diff --git a/docs/traversing.rst b/docs/traversing.rst
index a368de8..0643887 100644
--- a/docs/traversing.rst
+++ b/docs/traversing.rst
@@ -34,3 +34,9 @@ Breaking out of a level of traversal is also supported using end::
     [<p#hello.hello>, <p#test>]
 
 
+If you want to select a dotted id you need to escape the dot::
+
+    >>> d = pq('<p id="hello.you"><a/></p><p id="test"><a/></p>')
+    >>> d(r'#hello\.you')
+    [<p#hello.you>]
+
diff --git a/pyquery.egg-info/PKG-INFO b/pyquery.egg-info/PKG-INFO
index 3f6525d..d5859c8 100644
--- a/pyquery.egg-info/PKG-INFO
+++ b/pyquery.egg-info/PKG-INFO
@@ -1,15 +1,21 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: pyquery
-Version: 1.2.9
+Version: 1.4.4.dev0
 Summary: A jquery-like library for python
 Home-page: https://github.com/gawel/pyquery
-Author: Gael Pasgrimaud
-Author-email: gael@gawel.org
+Author: Olivier Lauzanne
+Author-email: olauzanne@gmail.com
+Maintainer: Gael Pasgrimaud
+Maintainer-email: gael@gawel.org
 License: BSD
 Description: 
         pyquery: a jquery-like library for python
         =========================================
         
+        .. image:: https://travis-ci.org/gawel/pyquery.svg
+           :alt: Build Status
+           :target: https://travis-ci.org/gawel/pyquery
+        
         pyquery allows you to make jquery queries on xml documents.
         The API is as much as possible the similar to jquery. pyquery uses lxml for fast
         xml and html manipulation.
@@ -20,7 +26,7 @@ Description:
         
         The `project`_ is being actively developped on a git repository on Github. I
         have the policy of giving push access to anyone who wants it and then to review
-        what he does. So if you want to contribute just email me.
+        what they do. So if you want to contribute just email me.
         
         Please report bugs on the `github
         <https://github.com/gawel/pyquery/issues>`_ issue
@@ -29,6 +35,18 @@ Description:
         .. _deliverance: http://www.gawel.org/weblog/en/2008/12/skinning-with-pyquery-and-deliverance
         .. _project: https://github.com/gawel/pyquery/
         
+        I've spent hours maintaining this software, with love.
+        Please consider tiping if you like it:
+        
+        BTC: 1PruQAwByDndFZ7vTeJhyWefAghaZx9RZg
+        
+        ETH: 0xb6418036d8E06c60C4D91c17d72Df6e1e5b15CE6
+        
+        LTC: LY6CdZcDbxnBX9GFBJ45TqVj8NykBBqsmT
+        
+        ..
+           >>> (urlopen, your_url, path_to_html_file) = getfixture('readme_fixt')
+        
         Quickstart
         ==========
         
@@ -73,6 +91,129 @@ Description:
         News
         ====
         
+        1.4.4 (unreleased)
+        ------------------
+        
+        - Add nextUntil method
+        
+        
+        1.4.3 (2020-11-21)
+        ------------------
+        
+        - No longer use a universal wheel
+        
+        
+        1.4.2 (2020-11-21)
+        ------------------
+        
+        - Fix exception raised when calling `PyQuery("<textarea></textarea>").text()`
+        
+        - python2 is no longer supported
+        
+        1.4.1 (2019-10-26)
+        ------------------
+        
+        - This is the latest release with py2 support
+        
+        - Remove py33, py34 support
+        
+        - web scraping improvements: default timeout and session support
+        
+        - Add API methods to serialize form-related elements according to spec
+        
+        - Include HTML markup when querying textarea text/value
+        
+        
+        1.4.0 (2018-01-11)
+        ------------------
+        
+        - Refactoring of `.text()` to match firefox behavior.
+        
+        
+        1.3.0 (2017-10-21)
+        ------------------
+        
+        - Remove some unmaintained modules: ``pyquery.ajax`` and ``pyquery.rules``
+        
+        - Code cleanup. No longer use ugly hacks required by python2.6/python3.2.
+        
+        - Run tests with python3.6 on CI
+        
+        - Add a ``method`` argument to ``.outer_html()``
+        
+        
+        1.2.17 (2016-10-14)
+        -------------------
+        
+        - ``PyQuery('<input value="">').val()`` is ``''``
+        - ``PyQuery('<input>').val()`` is ``''``
+        
+        
+        1.2.16 (2016-10-14)
+        -------------------
+        
+        - ``.attr('value', '')`` no longer removes the ``value`` attribute
+        
+        - ``<input type="checkbox">`` without ``value="..."`` have a ``.val()`` of
+          ``'on'``
+        
+        - ``<input type="radio">`` without ``value="..."`` have a ``.val()`` of
+          ``'on'``
+        
+        - ``<select>`` without ``<option selected>`` have the value of their first
+          ``<option>`` (or ``None`` if there are no options)
+        
+        
+        1.2.15 (2016-10-11)
+        -------------------
+        
+        - .val() should never raise
+        
+        - drop py26 support
+        
+        - improve .extend() by returning self
+        
+        
+        1.2.14 (2016-10-10)
+        -------------------
+        
+        - fix val() for <textarea> and <select>, to match jQuery behavior
+        
+        
+        1.2.13 (2016-04-12)
+        -------------------
+        
+        - Note explicit support for Python 3.5
+        
+        1.2.12 (2016-04-12)
+        -------------------
+        
+        - make_links_absolute now take care of whitespaces
+        
+        - added pseudo selector :has()
+        
+        - add cookies arguments as allowed arguments for requests
+        
+        
+        1.2.11 (2016-02-02)
+        -------------------
+        
+        - Preserve namespaces attribute on PyQuery copies.
+        
+        - Do not raise an error when the http response code is 2XX
+        
+        1.2.10 (2016-01-05)
+        -------------------
+        
+        - Fixed #118: implemented usage ``lxml.etree.tostring`` within ``outer_html`` method
+        
+        - Fixed #117: Raise HTTP Error if HTTP status code is not equal to 200
+        
+        - Fixed #112: make_links_absolute does not apply to form actions
+        
+        - Fixed #98: contains act like jQuery
+        
+        
         1.2.9 (2014-08-22)
         ------------------
         
@@ -115,111 +256,110 @@ Description:
         1.2.6 (2013-10-11)
         ------------------
         
-        README_fixt.py was not include in the release. Fix #54.
+        - README_fixt.py was not include in the release. Fix #54.
         
         
         1.2.5 (2013-10-10)
         ------------------
         
-        cssselect compat. See https://github.com/SimonSapin/cssselect/pull/22
+        - cssselect compat. See https://github.com/SimonSapin/cssselect/pull/22
         
-        tests improvments. no longer require a eth connection.
+        - tests improvments. no longer require a eth connection.
         
-        fix #55
+        - fix #55
         
         1.2.4
         -----
         
-        Moved to github. So a few files are renamed from .txt to .rst
+        - Moved to github. So a few files are renamed from .txt to .rst
         
-        Added .xhtml_to_html() and .remove_namespaces()
+        - Added .xhtml_to_html() and .remove_namespaces()
         
-        Use requests to fetch urls (if available)
+        - Use requests to fetch urls (if available)
         
-        Use restkit's proxy instead of Paste (which will die with py3)
+        - Use restkit's proxy instead of Paste (which will die with py3)
         
-        Allow to open https urls
+        - Allow to open https urls
         
-        python2.5 is no longer supported (may work, but tests are broken)
+        - python2.5 is no longer supported (may work, but tests are broken)
         
         1.2.3
         -----
         
-        Allow to pass this in .filter() callback
+        - Allow to pass this in .filter() callback
         
-        Add .contents() .items()
+        - Add .contents() .items()
         
-        Add tox.ini
+        - Add tox.ini
         
-        Bug fixes: fix #35 #55 #64 #66
+        - Bug fixes: fix #35 #55 #64 #66
         
         1.2.2
         -----
         
-        Fix cssselectpatch to match the newer implementation of cssselect. Fixes issue #62, #52 and #59 (Haoyu Bai)
+        - Fix cssselectpatch to match the newer implementation of cssselect. Fixes issue #62, #52 and #59 (Haoyu Bai)
         
-        Fix issue #37 (Caleb Burns)
+        - Fix issue #37 (Caleb Burns)
         
         1.2.1
         -----
         
-        Allow to use a custom css translator.
+        - Allow to use a custom css translator.
         
-        Fix issue 44: case problem with xml documents
+        - Fix issue 44: case problem with xml documents
         
         1.2
         ---
         
-        PyQuery now use `cssselect <http://pypi.python.org/pypi/cssselect>`_. See issue
-        43.
+        - PyQuery now uses `cssselect <http://pypi.python.org/pypi/cssselect>`_. See issue 43.
         
-        Fix issue 40: forward .html() extra arguments to ``lxml.etree.tostring``
+        - Fix issue 40: forward .html() extra arguments to ``lxml.etree.tostring``
         
         1.1.1
         -----
         
-        Minor release. Include test file so you can run tests from the tarball.
+        - Minor release. Include test file so you can run tests from the tarball.
         
         
         1.1
         ---
         
-        fix issues 30, 31, 32 - py3 improvements / webob 1.2+ support
+        - fix issues 30, 31, 32 - py3 improvements / webob 1.2+ support
         
         
         1.0
         ---
         
-        fix issues 24
+        - fix issues 24
         
         0.7
         ---
         
-        Python 3 compatible
+        - Python 3 compatible
         
-        Add __unicode__ method
+        - Add __unicode__ method
         
-        Add root and encoding attribute
+        - Add root and encoding attribute
         
-        fix issues 19, 20, 22, 23 
+        - fix issues 19, 20, 22, 23 
         
         0.6.1
         ------
         
-        Move README.txt at package root
+        - Move README.txt at package root
         
-        Add CHANGES.txt and add it to long_description
+        - Add CHANGES.txt and add it to long_description
         
         0.6
         ----
         
-        Added PyQuery.outerHtml
+        - Added PyQuery.outerHtml
         
-        Added PyQuery.fn
+        - Added PyQuery.fn
         
-        Added PyQuery.map
+        - Added PyQuery.map
         
-        Change PyQuery.each behavior to reflect jQuery api
+        - Change PyQuery.each behavior to reflect jQuery api
         
         
         
@@ -229,9 +369,8 @@ Keywords: jquery html xml scraping
 Platform: UNKNOWN
 Classifier: Intended Audience :: Developers
 Classifier: Development Status :: 5 - Production/Stable
-Classifier: Programming Language :: Python :: 2
-Classifier: Programming Language :: Python :: 2.6
-Classifier: Programming Language :: Python :: 2.7
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.3
-Classifier: Programming Language :: Python :: 3.4
+Classifier: Programming Language :: Python :: 3.5
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Provides-Extra: test
diff --git a/pyquery.egg-info/SOURCES.txt b/pyquery.egg-info/SOURCES.txt
index d925d5f..577c8f1 100644
--- a/pyquery.egg-info/SOURCES.txt
+++ b/pyquery.egg-info/SOURCES.txt
@@ -1,35 +1,33 @@
 CHANGES.rst
+LICENSE.txt
 MANIFEST.in
 README.rst
 README_fixt.py
-buildout.cfg
+conftest.py
+pytest.ini
 setup.cfg
 setup.py
 tox.ini
 docs/Makefile
-docs/ajax.rst
-docs/ajax_fixt.py
 docs/api.rst
 docs/attributes.rst
 docs/changes.rst
 docs/conf.py
+docs/conftest.py
 docs/css.rst
 docs/future.rst
 docs/index.rst
 docs/manipulating.rst
 docs/pseudo_classes.rst
 docs/scrap.rst
-docs/scrap_fixt.py
 docs/testing.rst
 docs/tips.rst
-docs/tips_fixt.py
 docs/traversing.rst
 pyquery/__init__.py
-pyquery/ajax.py
 pyquery/cssselectpatch.py
 pyquery/openers.py
 pyquery/pyquery.py
-pyquery/rules.py
+pyquery/text.py
 pyquery.egg-info/PKG-INFO
 pyquery.egg-info/SOURCES.txt
 pyquery.egg-info/dependency_links.txt
@@ -39,8 +37,12 @@ pyquery.egg-info/requires.txt
 pyquery.egg-info/top_level.txt
 tests/__init__.py
 tests/apps.py
-tests/compat.py
+tests/browser_base.py
 tests/doctests.rst
+tests/geckodriver.sh
 tests/invalid.xml
+tests/selenium.sh
 tests/test.html
-tests/test_pyquery.py
\ No newline at end of file
+tests/test_browser.py
+tests/test_pyquery.py
+tests/test_real_browser.py
\ No newline at end of file
diff --git a/pyquery.egg-info/requires.txt b/pyquery.egg-info/requires.txt
index fec943e..e41fda2 100644
--- a/pyquery.egg-info/requires.txt
+++ b/pyquery.egg-info/requires.txt
@@ -1,2 +1,9 @@
+cssselect>0.7.9
 lxml>=2.1
-cssselect
+
+[test]
+pytest
+pytest-cov
+requests
+webob
+webtest
diff --git a/pyquery/__init__.py b/pyquery/__init__.py
index b58424a..cdc64b1 100644
--- a/pyquery/__init__.py
+++ b/pyquery/__init__.py
@@ -1,14 +1,5 @@
-#-*- coding:utf-8 -*-
-#
 # Copyright (C) 2008 - Olivier Lauzanne <olauzanne@gmail.com>
 #
 # Distributed under the BSD license, see LICENSE.txt
 
-try:
-    import webob
-    import restkit
-except ImportError:
-    from .pyquery import PyQuery
-else:
-    from .ajax import PyQuery
-
+from .pyquery import PyQuery  # NOQA
diff --git a/pyquery/ajax.py b/pyquery/ajax.py
deleted file mode 100644
index b7b0a79..0000000
--- a/pyquery/ajax.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# -*- coding: utf-8 -*-
-from .pyquery import PyQuery as Base
-from .pyquery import no_default
-
-from webob import Request
-from webob import Response
-
-try:
-    from restkit.contrib.wsgi_proxy import HostProxy
-except ImportError:
-    HostProxy = no_default  # NOQA
-
-
-class PyQuery(Base):
-
-    def __init__(self, *args, **kwargs):
-        if 'response' in kwargs:
-            self.response = kwargs.pop('response')
-        else:
-            self.response = Response()
-        if 'app' in kwargs:
-            self.app = kwargs.pop('app')
-            if len(args) == 0:
-                args = [[]]
-        else:
-            self.app = no_default
-        Base.__init__(self, *args, **kwargs)
-        if self._parent is not no_default:
-            self.app = self._parent.app
-
-    def _wsgi_get(self, path_info, **kwargs):
-        if path_info.startswith('/'):
-            if 'app' in kwargs:
-                app = kwargs.pop('app')
-            elif self.app is not no_default:
-                app = self.app
-            else:
-                raise ValueError('There is no app available')
-        else:
-            if HostProxy is not no_default:
-                app = HostProxy(path_info)
-                path_info = '/'
-            else:
-                raise ImportError('restkit is not installed')
-
-        environ = kwargs.pop('environ').copy()
-        environ.update(kwargs)
-
-        # unsuported (came from Deliverance)
-        for key in ['HTTP_ACCEPT_ENCODING', 'HTTP_IF_MATCH',
-                    'HTTP_IF_UNMODIFIED_SINCE', 'HTTP_RANGE', 'HTTP_IF_RANGE']:
-            if key in environ:
-                del environ[key]
-
-        req = Request.blank(path_info)
-        req.environ.update(environ)
-        resp = req.get_response(app)
-        status = resp.status.split()
-        ctype = resp.content_type.split(';')[0]
-        if status[0] not in '45' and ctype == 'text/html':
-            body = resp.body
-        else:
-            body = []
-        result = self.__class__(body,
-                                parent=self._parent,
-                                app=self.app,  # always return self.app
-                                response=resp)
-        return result
-
-    def get(self, path_info, **kwargs):
-        """GET a path from wsgi app or url
-        """
-        environ = kwargs.setdefault('environ', {})
-        environ['REQUEST_METHOD'] = 'GET'
-        environ['CONTENT_LENGTH'] = '0'
-        return self._wsgi_get(path_info, **kwargs)
-
-    def post(self, path_info, **kwargs):
-        """POST a path from wsgi app or url
-        """
-        environ = kwargs.setdefault('environ', {})
-        environ['REQUEST_METHOD'] = 'POST'
-        return self._wsgi_get(path_info, **kwargs)
diff --git a/pyquery/cssselectpatch.py b/pyquery/cssselectpatch.py
index a50657e..ec94225 100644
--- a/pyquery/cssselectpatch.py
+++ b/pyquery/cssselectpatch.py
@@ -1,5 +1,3 @@
-#-*- coding:utf-8 -*-
-#
 # Copyright (C) 2008 - Olivier Lauzanne <olauzanne@gmail.com>
 #
 # Distributed under the BSD license, see LICENSE.txt
@@ -127,6 +125,26 @@ class JQueryTranslator(cssselect_xpath.HTMLTranslator):
         xpath.add_condition("@selected and name(.) = 'option'")
         return xpath
 
+    def _format_disabled_xpath(self, disabled=True):
+        """Format XPath condition for :disabled or :enabled pseudo-classes
+        according to the WHATWG spec. See: https://html.spec.whatwg.org
+        /multipage/semantics-other.html#concept-element-disabled
+        """
+        bool_op = '' if disabled else 'not'
+        return '''(
+            ((name(.) = 'button' or name(.) = 'input' or name(.) = 'select'
+                    or name(.) = 'textarea' or name(.) = 'fieldset')
+                and %s(@disabled or (ancestor::fieldset[@disabled]
+                    and not(ancestor::legend[not(preceding-sibling::legend)])))
+            )
+            or
+            ((name(.) = 'option'
+                and %s(@disabled or ancestor::optgroup[@disabled]))
+            )
+            or
+            ((name(.) = 'optgroup' and %s(@disabled)))
+            )''' % (bool_op, bool_op, bool_op)
+
     def xpath_disabled_pseudo(self, xpath):
         """Matches all elements that are disabled::
 
@@ -137,7 +155,7 @@ class JQueryTranslator(cssselect_xpath.HTMLTranslator):
 
         ..
         """
-        xpath.add_condition("@disabled")
+        xpath.add_condition(self._format_disabled_xpath())
         return xpath
 
     def xpath_enabled_pseudo(self, xpath):
@@ -150,7 +168,7 @@ class JQueryTranslator(cssselect_xpath.HTMLTranslator):
 
         ..
         """
-        xpath.add_condition("not(@disabled) and name(.) = 'input'")
+        xpath.add_condition(self._format_disabled_xpath(disabled=False))
         return xpath
 
     def xpath_file_pseudo(self, xpath):
@@ -337,11 +355,11 @@ class JQueryTranslator(cssselect_xpath.HTMLTranslator):
             >>> from pyquery import PyQuery
             >>> d = PyQuery('<div><h1><span>title</span></h1><h2/></div>')
             >>> d(':empty')
-            [<span>, <h2>]
+            [<h2>]
 
         ..
         """
-        xpath.add_condition("count(child::*) = 0")
+        xpath.add_condition("not(node())")
         return xpath
 
     def xpath_eq_function(self, xpath, function):
@@ -406,16 +424,43 @@ class JQueryTranslator(cssselect_xpath.HTMLTranslator):
 
             >>> from pyquery import PyQuery
             >>> d = PyQuery('<div><h1/><h1 class="title">title</h1></div>')
-            >>> d(':contains("title")')
+            >>> d('h1:contains("title")')
             [<h1.title>]
 
         ..
         """
-        if function.argument_types() != ['STRING']:
+        if function.argument_types() not in (['STRING'], ['IDENT']):
             raise ExpressionError(
-                "Expected a single string for :contains(), got %r" % (
+                "Expected a single string or ident for :contains(), got %r" % (
                     function.arguments,))
 
         value = self.xpath_literal(function.arguments[0].value)
-        xpath.add_post_condition("contains(text(), %s)" % value)
+        xpath.add_post_condition('contains(., %s)' % value)
+        return xpath
+
+    def xpath_has_function(self, xpath, function):
+        """Matches elements which contain at least one element that matches
+        the specified selector. https://api.jquery.com/has-selector/
+
+            >>> from pyquery import PyQuery
+            >>> d = PyQuery('<div class="foo"><div class="bar"></div></div>')
+            >>> d('.foo:has(".baz")')
+            []
+            >>> d('.foo:has(".foo")')
+            []
+            >>> d('.foo:has(".bar")')
+            [<div.foo>]
+            >>> d('.foo:has(div)')
+            [<div.foo>]
+
+        ..
+        """
+        if function.argument_types() not in (['STRING'], ['IDENT']):
+            raise ExpressionError(
+                "Expected a single string or ident for :has(), got %r" % (
+                    function.arguments,))
+        value = self.css_to_xpath(
+            function.arguments[0].value, prefix='descendant::',
+        )
+        xpath.add_post_condition(value)
         return xpath
diff --git a/pyquery/openers.py b/pyquery/openers.py
index 030665f..81332f2 100644
--- a/pyquery/openers.py
+++ b/pyquery/openers.py
@@ -1,15 +1,7 @@
 # -*- coding: utf-8 -*-
-import sys
-
-PY3k = sys.version_info >= (3,)
-
-if PY3k:
-    from urllib.request import urlopen
-    from urllib.parse import urlencode
-    basestring = (str, bytes)
-else:
-    from urllib2 import urlopen  # NOQA
-    from urllib import urlencode  # NOQA
+from urllib.request import urlopen
+from urllib.parse import urlencode
+from urllib.error import HTTPError
 
 try:
     import requests
@@ -17,9 +9,14 @@ try:
 except ImportError:
     HAS_REQUEST = False
 
+DEFAULT_TIMEOUT = 60
+
+basestring = (str, bytes)
 
 allowed_args = (
-    'auth', 'data', 'headers', 'verify', 'cert', 'config', 'hooks', 'proxies')
+    'auth', 'data', 'headers', 'verify',
+    'cert', 'config', 'hooks', 'proxies', 'cookies'
+)
 
 
 def _query(url, method, kwargs):
@@ -38,22 +35,30 @@ def _query(url, method, kwargs):
         url += data
         data = None
 
-    if data and PY3k:
+    if data:
         data = data.encode('utf-8')
     return url, data
 
 
 def _requests(url, kwargs):
+
     encoding = kwargs.get('encoding')
     method = kwargs.get('method', 'get').lower()
-    meth = getattr(requests, str(method))
+    session = kwargs.get('session')
+    if session:
+        meth = getattr(session, str(method))
+    else:
+        meth = getattr(requests, str(method))
     if method == 'get':
         url, data = _query(url, method, kwargs)
     kw = {}
     for k in allowed_args:
         if k in kwargs:
             kw[k] = kwargs[k]
-    resp = meth(url=url, **kw)
+    resp = meth(url=url, timeout=kwargs.get('timeout', DEFAULT_TIMEOUT), **kw)
+    if not (200 <= resp.status_code < 300):
+        raise HTTPError(resp.url, resp.status_code,
+                        resp.reason, resp.headers, None)
     if encoding:
         resp.encoding = encoding
     html = resp.text
@@ -63,7 +68,7 @@ def _requests(url, kwargs):
 def _urllib(url, kwargs):
     method = kwargs.get('method')
     url, data = _query(url, method, kwargs)
-    return urlopen(url, data)
+    return urlopen(url, data, timeout=kwargs.get('timeout', DEFAULT_TIMEOUT))
 
 
 def url_opener(url, kwargs):
diff --git a/pyquery/pyquery.py b/pyquery/pyquery.py
index 224ae8a..80da386 100644
--- a/pyquery/pyquery.py
+++ b/pyquery/pyquery.py
@@ -1,42 +1,34 @@
-#-*- coding:utf-8 -*-
-#
 # Copyright (C) 2008 - Olivier Lauzanne <olauzanne@gmail.com>
 #
 # Distributed under the BSD license, see LICENSE.txt
 from .cssselectpatch import JQueryTranslator
+from collections import OrderedDict
+from urllib.parse import urlencode
+from urllib.parse import urljoin
 from .openers import url_opener
+from .text import extract_text
 from copy import deepcopy
 from lxml import etree
 import lxml.html
 import inspect
+import itertools
 import types
-import sys
-
-
-PY3k = sys.version_info >= (3,)
-
-if PY3k:
-    from urllib.parse import urlencode
-    from urllib.parse import urljoin
-    basestring = (str, bytes)
-    unicode = str
-else:
-    from urllib import urlencode  # NOQA
-    from urlparse import urljoin  # NOQA
-
 
-def func_globals(f):
-    return f.__globals__ if PY3k else f.func_globals
+basestring = (str, bytes)
 
 
-def func_code(f):
-    return f.__code__ if PY3k else f.func_code
+def getargspec(func):
+    args = inspect.signature(func).parameters.values()
+    return [p.name for p in args
+            if p.kind == p.POSITIONAL_OR_KEYWORD]
 
 
 def with_camel_case_alias(func):
     """decorator for methods who required a camelcase alias"""
     _camel_case_aliases.add(func.__name__)
     return func
+
+
 _camel_case_aliases = set()
 
 
@@ -46,8 +38,8 @@ def build_camel_case_aliases(PyQuery):
         parts = list(alias.split('_'))
         name = parts[0] + ''.join([p.title() for p in parts[1:]])
         func = getattr(PyQuery, alias)
-        f = types.FunctionType(func_code(func), func_globals(func),
-                               name, inspect.getargspec(func).defaults)
+        f = types.FunctionType(func.__code__, func.__globals__,
+                               name, func.__defaults__)
         f.__doc__ = (
             'Alias for :func:`~pyquery.pyquery.PyQuery.%s`') % func.__name__
         setattr(PyQuery, name, f.__get__(None, PyQuery))
@@ -99,7 +91,7 @@ def fromstring(context, parser=None, custom_parser=None):
 
 
 def callback(func, *args):
-    return func(*args[:func_code(func).co_argcount])
+    return func(*args[:func.__code__.co_argcount])
 
 
 class NoDefault(object):
@@ -107,6 +99,7 @@ class NoDefault(object):
         """clean representation in Sphinx"""
         return '<NoDefault>'
 
+
 no_default = NoDefault()
 del NoDefault
 
@@ -157,8 +150,7 @@ class PyQuery(list):
         self.parser = kwargs.pop('parser', None)
 
         if (len(args) >= 1 and
-                (not PY3k and isinstance(args[0], basestring) or
-                (PY3k and isinstance(args[0], str))) and
+                isinstance(args[0], str) and
                 args[0].split('://', 1)[0] in ('http', 'https')):
             kwargs['url'] = args[0]
             if len(args) >= 2:
@@ -179,7 +171,7 @@ class PyQuery(list):
         else:
             self._translator = self._translator_class(xhtml=False)
 
-        namespaces = kwargs.pop('namespaces', {})
+        self.namespaces = kwargs.pop('namespaces', None)
 
         if kwargs:
             # specific case to get the dom
@@ -203,7 +195,7 @@ class PyQuery(list):
             if hasattr(html, 'close'):
                 try:
                     html.close()
-                except:
+                except Exception:
                     pass
 
         else:
@@ -233,13 +225,16 @@ class PyQuery(list):
                 elements = context
             elif isinstance(context, etree._Element):
                 elements = [context]
+            else:
+                raise TypeError(context)
 
             # select nodes
             if elements and selector is not no_default:
                 xpath = self._css_to_xpath(selector)
                 results = []
                 for tag in elements:
-                    results.extend(tag.xpath(xpath, namespaces=namespaces))
+                    results.extend(
+                        tag.xpath(xpath, namespaces=self.namespaces))
                 elements = results
 
         list.__init__(self, elements)
@@ -248,6 +243,10 @@ class PyQuery(list):
         selector = selector.replace('[@', '[')
         return self._translator.css_to_xpath(selector, prefix)
 
+    def _copy(self, *args, **kwargs):
+        kwargs.setdefault('namespaces', self.namespaces)
+        return self.__class__(*args, **kwargs)
+
     def __call__(self, *args, **kwargs):
         """return a new PyQuery instance
         """
@@ -255,13 +254,12 @@ class PyQuery(list):
         if length == 0:
             raise ValueError('You must provide at least a selector')
         if args[0] == '':
-            return self.__class__([])
+            return self._copy([])
         if (len(args) == 1 and
-                (not PY3k and isinstance(args[0], basestring) or
-                (PY3k and isinstance(args[0], str))) and
+                isinstance(args[0], str) and
                 not args[0].startswith('<')):
             args += (self,)
-        result = self.__class__(*args, parent=self, **kwargs)
+        result = self._copy(*args, parent=self, **kwargs)
         return result
 
     # keep original list api prefixed with _
@@ -271,12 +269,13 @@ class PyQuery(list):
     # improve pythonic api
     def __add__(self, other):
         assert isinstance(other, self.__class__)
-        return self.__class__(self[:] + other[:])
+        return self._copy(self[:] + other[:])
 
     def extend(self, other):
         """Extend with anoter PyQuery object"""
         assert isinstance(other, self.__class__)
         self._extend(other[:])
+        return self
 
     def items(self, selector=None):
         """Iter over elements. Return PyQuery objects:
@@ -294,7 +293,7 @@ class PyQuery(list):
         else:
             elems = self
         for elem in elems:
-            yield self.__class__(elem, **dict(parent=self))
+            yield self._copy(elem, parent=self)
 
     def xhtml_to_html(self):
         """Remove xhtml namespace:
@@ -342,15 +341,12 @@ class PyQuery(list):
             <script>&lt;![[CDATA[ ]&gt;</script>
 
         """
-        if PY3k:
-            return ''.join([etree.tostring(e, encoding=str) for e in self])
-        else:
-            return ''.join([etree.tostring(e) for e in self])
+        return ''.join([etree.tostring(e, encoding=str) for e in self])
 
     def __unicode__(self):
         """xml representation of current nodes"""
-        return unicode('').join([etree.tostring(e, encoding=unicode)
-                                 for e in self])
+        return u''.join([etree.tostring(e, encoding=str)
+                         for e in self])
 
     def __html__(self):
         """html representation of current nodes::
@@ -361,8 +357,8 @@ class PyQuery(list):
             <script><![[CDATA[ ]></script>
 
         """
-        return unicode('').join([lxml.html.tostring(e, encoding=unicode)
-                                 for e in self])
+        return u''.join([lxml.html.tostring(e, encoding=str)
+                         for e in self])
 
     def __repr__(self):
         r = []
@@ -375,22 +371,14 @@ class PyQuery(list):
                 r.append('<%s%s%s>' % (el.tag, id, c))
             return '[' + (', '.join(r)) + ']'
         except AttributeError:
-            if PY3k:
-                return list.__repr__(self)
-            else:
-                for el in self:
-                    if isinstance(el, unicode):
-                        r.append(el.encode('utf-8'))
-                    else:
-                        r.append(el)
-                return repr(r)
+            return list.__repr__(self)
 
     @property
     def root(self):
         """return the xml root element
         """
         if self._parent is not no_default:
-            return self._parent.getroottree()
+            return self._parent[0].getroottree()
         return self[0].getroottree()
 
     @property
@@ -415,16 +403,16 @@ class PyQuery(list):
             xpath = self._css_to_xpath(selector, 'self::')
             results = []
             for tag in elements:
-                results.extend(tag.xpath(xpath))
+                results.extend(tag.xpath(xpath, namespaces=self.namespaces))
         if reverse:
             results.reverse()
         if unique:
             result_list = results
             results = []
             for item in result_list:
-                if not item in results:
+                if item not in results:
                     results.append(item)
-        return self.__class__(results, **dict(parent=self))
+        return self._copy(results, parent=self)
 
     def parent(self, selector=None):
         return self._filter_only(
@@ -475,6 +463,27 @@ class PyQuery(list):
         """
         return self._filter_only(selector, self._next_all())
 
+    @with_camel_case_alias
+    def next_until(self, selector, filter_=None):
+        """
+        >>> h = '''
+        ... <h2>Greeting 1</h2>
+        ... <p>Hello!</p><p>World!</p>
+        ... <h2>Greeting 2</h2><p>Bye!</p>
+        ... '''
+        >>> d = PyQuery(h)
+        >>> d('h2:first').nextUntil('h2')
+        [<p>, <p>]
+        """
+        return self._filter_only(
+            filter_, [
+                e
+                for q in itertools.takewhile(
+                    lambda q: not q.is_(selector), self.next_all().items())
+                for e in q
+            ]
+        )
+
     def _prev_all(self):
         return [e for e in self._traverse('getprevious')]
 
@@ -548,11 +557,11 @@ class PyQuery(list):
         result = []
         for current in self:
             while (current is not None and
-                    not self.__class__(current).is_(selector)):
+                    not self._copy(current).is_(selector)):
                 current = current.getparent()
             if current is not None:
                 result.append(current)
-        return self.__class__(result, **dict(parent=self))
+        return self._copy(result, parent=self)
 
     def contents(self):
         """
@@ -564,8 +573,9 @@ class PyQuery(list):
         """
         results = []
         for elem in self:
-            results.extend(elem.xpath('child::text()|child::*'))
-        return self.__class__(results, **dict(parent=self))
+            results.extend(elem.xpath('child::text()|child::*',
+                           namespaces=self.namespaces))
+        return self._copy(results, parent=self)
 
     def filter(self, selector):
         """Filter elements in self using selector (string or function):
@@ -586,18 +596,18 @@ class PyQuery(list):
             return self._filter_only(selector, self)
         else:
             elements = []
-            args = inspect.getargspec(callback).args
+            args = getargspec(callback)
             try:
                 for i, this in enumerate(self):
                     if len(args) == 1:
-                        func_globals(selector)['this'] = this
+                        selector.__globals__['this'] = this
                     if callback(selector, i, this):
                         elements.append(this)
             finally:
-                f_globals = func_globals(selector)
+                f_globals = selector.__globals__
                 if 'this' in f_globals:
                     del f_globals['this']
-            return self.__class__(elements, **dict(parent=self))
+            return self._copy(elements, parent=self)
 
     def not_(self, selector):
         """Return elements that don't match the given selector:
@@ -606,9 +616,9 @@ class PyQuery(list):
             >>> d('p').not_('.hello')
             [<p>]
         """
-        exclude = set(self.__class__(selector, self))
-        return self.__class__([e for e in self if e not in exclude],
-                              **dict(parent=self))
+        exclude = set(self._copy(selector, self))
+        return self._copy([e for e in self if e not in exclude],
+                          parent=self)
 
     def is_(self, selector):
         """Returns True if selector matches at least one current element, else
@@ -639,13 +649,14 @@ class PyQuery(list):
             [<em>]
         """
         xpath = self._css_to_xpath(selector)
-        results = [child.xpath(xpath) for tag in self
+        results = [child.xpath(xpath, namespaces=self.namespaces)
+                   for tag in self
                    for child in tag.getchildren()]
         # Flatten the results
         elements = []
         for r in results:
             elements.extend(r)
-        return self.__class__(elements, **dict(parent=self))
+        return self._copy(elements, parent=self)
 
     def eq(self, index):
         """Return PyQuery of only the element with the provided index::
@@ -660,20 +671,24 @@ class PyQuery(list):
 
         ..
         """
-        # Use slicing to silently handle out of bounds indexes
-        items = self[index:index + 1]
-        return self.__class__(items, **dict(parent=self))
+        # Slicing will return empty list when index=-1
+        # we should handle out of bound by ourselves
+        try:
+            items = self[index]
+        except IndexError:
+            items = []
+        return self._copy(items, parent=self)
 
     def each(self, func):
         """apply func on each nodes
         """
         try:
             for i, element in enumerate(self):
-                func_globals(func)['this'] = element
+                func.__globals__['this'] = element
                 if callback(func, i, element) is False:
                     break
         finally:
-            f_globals = func_globals(func)
+            f_globals = func.__globals__
             if 'this' in f_globals:
                 del f_globals['this']
         return self
@@ -698,7 +713,7 @@ class PyQuery(list):
         items = []
         try:
             for i, element in enumerate(self):
-                func_globals(func)['this'] = element
+                func.__globals__['this'] = element
                 result = callback(func, i, element)
                 if result is not None:
                     if not isinstance(result, list):
@@ -706,10 +721,10 @@ class PyQuery(list):
                     else:
                         items.extend(result)
         finally:
-            f_globals = func_globals(func)
+            f_globals = func.__globals__
             if 'this' in f_globals:
                 del f_globals['this']
-        return self.__class__(items, **dict(parent=self))
+        return self._copy(items, parent=self)
 
     @property
     def length(self):
@@ -760,7 +775,7 @@ class PyQuery(list):
                     tag.set(key, value)
         elif value is no_default:
             return self[0].get(attr)
-        elif value is None or value == '':
+        elif value is None:
             return self.remove_attr(attr)
         else:
             for tag in self:
@@ -924,7 +939,7 @@ class PyQuery(list):
     # CORE UI EFFECTS #
     ###################
     def hide(self):
-        """remove display:none to elements style
+        """Remove display:none to elements style:
 
             >>> print(PyQuery('<div style="display:none;"/>').hide())
             <div style="display: none"/>
@@ -933,7 +948,7 @@ class PyQuery(list):
         return self.css('display', 'none')
 
     def show(self):
-        """add display:block to elements style
+        """Add display:block to elements style:
 
             >>> print(PyQuery('<div />').show())
             <div style="display: block"/>
@@ -956,8 +971,90 @@ class PyQuery(list):
             >>> d.val()
             'Youhou'
 
+        Set the selected values for a `select` element with the `multiple`
+        attribute::
+
+            >>> d = PyQuery('''
+            ...             <select multiple>
+            ...                 <option value="you"><option value="hou">
+            ...             </select>
+            ...             ''')
+            >>> d.val(['you', 'hou'])
+            [<select>]
+
+        Get the selected values for a `select` element with the `multiple`
+        attribute::
+
+            >>> d.val()
+            ['you', 'hou']
+
         """
-        return self.attr('value', value) or None
+        def _get_value(tag):
+            # <textarea>
+            if tag.tag == 'textarea':
+                return self._copy(tag).html()
+            # <select>
+            elif tag.tag == 'select':
+                if 'multiple' in tag.attrib:
+                    # Only extract value if selected
+                    selected = self._copy(tag)('option[selected]')
+                    # Rebuild list to avoid serialization error
+                    return list(selected.map(
+                        lambda _, o: self._copy(o).attr('value')
+                    ))
+                selected_option = self._copy(tag)('option[selected]:last')
+                if selected_option:
+                    return selected_option.attr('value')
+                else:
+                    return self._copy(tag)('option').attr('value')
+            # <input type="checkbox"> or <input type="radio">
+            elif self.is_(':checkbox,:radio'):
+                val = self._copy(tag).attr('value')
+                if val is None:
+                    return 'on'
+                else:
+                    return val
+            # <input>
+            elif tag.tag == 'input':
+                val = self._copy(tag).attr('value')
+                return val.replace('\n', '') if val else ''
+            # everything else.
+            return self._copy(tag).attr('value') or ''
+
+        def _set_value(pq, value):
+            for tag in pq:
+                # <select>
+                if tag.tag == 'select':
+                    if not isinstance(value, list):
+                        value = [value]
+
+                    def _make_option_selected(_, elem):
+                        pq = self._copy(elem)
+                        if pq.attr('value') in value:
+                            pq.attr('selected', 'selected')
+                            if 'multiple' not in tag.attrib:
+                                del value[:]  # Ensure it toggles first match
+                        else:
+                            pq.removeAttr('selected')
+
+                    self._copy(tag)('option').each(_make_option_selected)
+                    continue
+                # Stringify array
+                if isinstance(value, list):
+                    value = ','.join(value)
+                # <textarea>
+                if tag.tag == 'textarea':
+                    self._copy(tag).text(value)
+                    continue
+                # <input> and everything else.
+                self._copy(tag).attr('value', value)
+
+        if value is no_default:
+            if len(self):
+                return _get_value(self[0])
+        else:
+            _set_value(self, value)
+            return self
 
     def html(self, value=no_default, **kwargs):
         """Get or set the html representation of sub nodes.
@@ -989,16 +1086,16 @@ class PyQuery(list):
             tag = self[0]
             children = tag.getchildren()
             if not children:
-                return tag.text
+                return tag.text or ''
             html = tag.text or ''
             if 'encoding' not in kwargs:
-                kwargs['encoding'] = unicode
-            html += unicode('').join([etree.tostring(e, **kwargs)
-                                      for e in children])
+                kwargs['encoding'] = str
+            html += u''.join([etree.tostring(e, **kwargs)
+                              for e in children])
             return html
         else:
             if isinstance(value, self.__class__):
-                new_html = unicode(value)
+                new_html = str(value)
             elif isinstance(value, basestring):
                 new_html = value
             elif not value:
@@ -1010,17 +1107,16 @@ class PyQuery(list):
                 for child in tag.getchildren():
                     tag.remove(child)
                 root = fromstring(
-                    unicode('<root>') + new_html + unicode('</root>'),
+                    u'<root>' + new_html + u'</root>',
                     self.parser)[0]
                 children = root.getchildren()
                 if children:
                     tag.extend(children)
                 tag.text = root.text
-                tag.tail = root.tail
         return self
 
     @with_camel_case_alias
-    def outer_html(self):
+    def outer_html(self, method="html"):
         """Get the html representation of the first selected element::
 
             >>> d = PyQuery('<div><span class="red">toto</span> rocks</div>')
@@ -1044,17 +1140,29 @@ class PyQuery(list):
         if e0.tail:
             e0 = deepcopy(e0)
             e0.tail = ''
-        return lxml.html.tostring(e0, encoding=unicode)
+        return etree.tostring(e0, encoding=str, method=method)
 
-    def text(self, value=no_default):
+    def text(self, value=no_default, **kwargs):
         """Get or set the text representation of sub nodes.
 
         Get the text value::
 
             >>> doc = PyQuery('<div><span>toto</span><span>tata</span></div>')
             >>> print(doc.text())
+            tototata
+            >>> doc = PyQuery('''<div><span>toto</span>
+            ...               <span>tata</span></div>''')
+            >>> print(doc.text())
             toto tata
 
+        Get the text value, without squashing newlines::
+
+            >>> doc = PyQuery('''<div><span>toto</span>
+            ...               <span>tata</span></div>''')
+            >>> print(doc.text(squash_space=False))
+            toto
+            tata
+
         Set the text value::
 
             >>> doc.text('Youhou !')
@@ -1067,20 +1175,10 @@ class PyQuery(list):
         if value is no_default:
             if not self:
                 return ''
-
-            text = []
-
-            def add_text(tag, no_tail=False):
-                if tag.text and not isinstance(tag, lxml.etree._Comment):
-                    text.append(tag.text)
-                for child in tag.getchildren():
-                    add_text(child)
-                if not no_tail and tag.tail:
-                    text.append(tag.tail)
-
-            for tag in self:
-                add_text(tag, no_tail=True)
-            return ' '.join([t.strip() for t in text if t.strip()])
+            return ' '.join(
+                self._copy(tag).html() if tag.tag == 'textarea' else
+                extract_text(tag, **kwargs) for tag in self
+            )
 
         for tag in self:
             for child in tag.getchildren():
@@ -1094,10 +1192,10 @@ class PyQuery(list):
 
     def _get_root(self, value):
         if isinstance(value, basestring):
-            root = fromstring(unicode('<root>') + value + unicode('</root>'),
+            root = fromstring(u'<root>' + value + u'</root>',
                               self.parser)[0]
         elif isinstance(value, etree._Element):
-            root = self.__class__(value)
+            root = self._copy(value)
         elif isinstance(value, PyQuery):
             root = value
         else:
@@ -1126,7 +1224,6 @@ class PyQuery(list):
             if i > 0:
                 root = deepcopy(list(root))
             tag.extend(root)
-            root = tag[-len(root):]
         return self
 
     @with_camel_case_alias
@@ -1300,7 +1397,7 @@ class PyQuery(list):
 
     @with_camel_case_alias
     def replace_with(self, value):
-        """replace nodes by value::
+        """replace nodes by value:
 
             >>> doc = PyQuery("<html><div /></html>")
             >>> node = PyQuery("<span />")
@@ -1315,13 +1412,13 @@ class PyQuery(list):
             value = str(value)
         if hasattr(value, '__call__'):
             for i, element in enumerate(self):
-                self.__class__(element).before(
+                self._copy(element).before(
                     value(i, element) + (element.tail or ''))
                 parent = element.getparent()
                 parent.remove(element)
         else:
             for tag in self:
-                self.__class__(tag).before(value + (tag.tail or ''))
+                self._copy(tag).before(value + (tag.tail or ''))
                 parent = tag.getparent()
                 parent.remove(tag)
         return self
@@ -1352,12 +1449,14 @@ class PyQuery(list):
     def remove(self, expr=no_default):
         """Remove nodes:
 
-         >>> h = '<div>Maybe <em>she</em> does <strong>NOT</strong> know</div>'
-         >>> d = PyQuery(h)
-         >>> d('strong').remove()
-         [<strong>]
-         >>> print(d)
-         <div>Maybe <em>she</em> does   know</div>
+             >>> h = (
+             ... '<div>Maybe <em>she</em> does <strong>NOT</strong> know</div>'
+             ... )
+             >>> d = PyQuery(h)
+             >>> d('strong').remove()
+             [<strong>]
+             >>> print(d)
+             <div>Maybe <em>she</em> does   know</div>
         """
         if expr is no_default:
             for tag in self:
@@ -1375,7 +1474,7 @@ class PyQuery(list):
                             prev.tail += ' ' + tag.tail
                     parent.remove(tag)
         else:
-            results = self.__class__(expr, self)
+            results = self._copy(expr, self)
             results.remove()
         return self
 
@@ -1394,16 +1493,142 @@ class PyQuery(list):
         """
         def __setattr__(self, name, func):
             def fn(self, *args, **kwargs):
-                func_globals(func)['this'] = self
+                func.__globals__['this'] = self
                 return func(*args, **kwargs)
             fn.__name__ = name
             setattr(PyQuery, name, fn)
     fn = Fn()
 
+    ########
+    # AJAX #
+    ########
+
+    @with_camel_case_alias
+    def serialize_array(self):
+        """Serialize form elements as an array of dictionaries, whose structure
+        mirrors that produced by the jQuery API. Notably, it does not handle
+        the deprecated `keygen` form element.
+
+            >>> d = PyQuery('<form><input name="order" value="spam"></form>')
+            >>> d.serialize_array() == [{'name': 'order', 'value': 'spam'}]
+            True
+            >>> d.serializeArray() == [{'name': 'order', 'value': 'spam'}]
+            True
+        """
+        return list(map(
+            lambda p: {'name': p[0], 'value': p[1]},
+            self.serialize_pairs()
+        ))
+
+    def serialize(self):
+        """Serialize form elements as a URL-encoded string.
+
+            >>> h = (
+            ... '<form><input name="order" value="spam">'
+            ... '<input name="order2" value="baked beans"></form>'
+            ... )
+            >>> d = PyQuery(h)
+            >>> d.serialize()
+            'order=spam&order2=baked%20beans'
+        """
+        return urlencode(self.serialize_pairs()).replace('+', '%20')
+
     #####################################################
     # Additional methods that are not in the jQuery API #
     #####################################################
 
+    @with_camel_case_alias
+    def serialize_pairs(self):
+        """Serialize form elements as an array of 2-tuples conventional for
+        typical URL-parsing operations in Python.
+
+            >>> d = PyQuery('<form><input name="order" value="spam"></form>')
+            >>> d.serialize_pairs()
+            [('order', 'spam')]
+            >>> d.serializePairs()
+            [('order', 'spam')]
+        """
+        # https://github.com/jquery/jquery/blob
+        # /2d4f53416e5f74fa98e0c1d66b6f3c285a12f0ce/src/serialize.js#L14
+        _submitter_types = ['submit', 'button', 'image', 'reset', 'file']
+
+        controls = self._copy([])
+        # Expand list of form controls
+        for el in self.items():
+            if el[0].tag == 'form':
+                form_id = el.attr('id')
+                if form_id:
+                    # Include inputs outside of their form owner
+                    root = self._copy(el.root.getroot())
+                    controls.extend(root(
+                        '#%s :not([form]):input, [form="%s"]:input'
+                        % (form_id, form_id)))
+                else:
+                    controls.extend(el(':not([form]):input'))
+            elif el[0].tag == 'fieldset':
+                controls.extend(el(':input'))
+            else:
+                controls.extend(el)
+        # Filter controls
+        selector = '[name]:enabled:not(button)'  # Not serializing image button
+        selector += ''.join(map(
+            lambda s: ':not([type="%s"])' % s,
+            _submitter_types))
+        controls = controls.filter(selector)
+
+        def _filter_out_unchecked(_, el):
+            el = controls._copy(el)
+            return not el.is_(':checkbox:not(:checked)') and \
+                not el.is_(':radio:not(:checked)')
+        controls = controls.filter(_filter_out_unchecked)
+
+        # jQuery serializes inputs with the datalist element as an ancestor
+        # contrary to WHATWG spec as of August 2018
+        #
+        # xpath = 'self::*[not(ancestor::datalist)]'
+        # results = []
+        # for tag in controls:
+        #     results.extend(tag.xpath(xpath, namespaces=controls.namespaces))
+        # controls = controls._copy(results)
+
+        # Serialize values
+        ret = []
+        for field in controls:
+            val = self._copy(field).val()
+            if isinstance(val, list):
+                ret.extend(map(
+                    lambda v: (field.attrib['name'], v.replace('\n', '\r\n')),
+                    val
+                ))
+            else:
+                ret.append((field.attrib['name'], val.replace('\n', '\r\n')))
+        return ret
+
+    @with_camel_case_alias
+    def serialize_dict(self):
+        """Serialize form elements as an ordered dictionary. Multiple values
+        corresponding to the same input name are concatenated into one list.
+
+            >>> d = PyQuery('''<form>
+            ...             <input name="order" value="spam">
+            ...             <input name="order" value="eggs">
+            ...             <input name="order2" value="ham">
+            ...             </form>''')
+            >>> d.serialize_dict()
+            OrderedDict([('order', ['spam', 'eggs']), ('order2', 'ham')])
+            >>> d.serializeDict()
+            OrderedDict([('order', ['spam', 'eggs']), ('order2', 'ham')])
+        """
+        ret = OrderedDict()
+        for name, val in self.serialize_pairs():
+            if name not in ret:
+                ret[name] = val
+            elif not isinstance(ret[name], list):
+                ret[name] = [ret[name], val]
+            else:
+                ret[name].append(val)
+        return ret
+
     @property
     def base_url(self):
         """Return the url of current html document or None if not available.
@@ -1423,12 +1648,30 @@ class PyQuery(list):
                     'You need a base URL to make your links'
                     'absolute. It can be provided by the base_url parameter.'))
 
-        def repl(i, e):
-            return self(e).attr(
-                'href',
-                urljoin(base_url, self(e).attr('href')))
+        def repl(attr):
+            def rep(i, e):
+                attr_value = self(e).attr(attr)
+                # when label hasn't such attr, pass
+                if attr_value is None:
+                    return None
+
+                # skip specific "protocol" schemas
+                if any(attr_value.startswith(schema)
+                       for schema in ('tel:', 'callto:', 'sms:')):
+                    return None
+
+                return self(e).attr(attr,
+                                    urljoin(base_url, attr_value.strip()))
+            return rep
+
+        self('a').each(repl('href'))
+        self('link').each(repl('href'))
+        self('script').each(repl('src'))
+        self('img').each(repl('src'))
+        self('iframe').each(repl('src'))
+        self('form').each(repl('action'))
 
-        self('a').each(repl)
         return self
 
+
 build_camel_case_aliases(PyQuery)
diff --git a/pyquery/rules.py b/pyquery/rules.py
deleted file mode 100644
index 5bb96a6..0000000
--- a/pyquery/rules.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# -*- coding: utf-8 -*-
-try:
-    from deliverance.pyref import PyReference
-    from deliverance import rules
-    from ajax import PyQuery as pq
-except ImportError:
-    pass
-else:
-    class PyQuery(rules.AbstractAction):
-        """Python function"""
-        name = 'py'
-        def __init__(self, source_location, pyref):
-            self.source_location = source_location
-            self.pyref = pyref
-
-        def apply(self, content_doc, theme_doc, resource_fetcher, log):
-            self.pyref(pq([content_doc]), pq([theme_doc]), resource_fetcher, log)
-
-        @classmethod
-        def from_xml(cls, el, source_location):
-            """Parses and instantiates the class from an element"""
-            pyref = PyReference.parse_xml(
-                el, source_location=source_location,
-                default_function='transform')
-            return cls(source_location, pyref)
-
-    rules._actions['pyquery'] = PyQuery
-
-    def deliverance_proxy():
-        import deliverance.proxycommand
-        deliverance.proxycommand.main()
diff --git a/pyquery/text.py b/pyquery/text.py
new file mode 100644
index 0000000..3f4ca7d
--- /dev/null
+++ b/pyquery/text.py
@@ -0,0 +1,111 @@
+import re
+
+
+# https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements#Elements
+INLINE_TAGS = {
+    'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'button', 'cite',
+    'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map',
+    'object', 'q', 'samp', 'script', 'select', 'small', 'span', 'strong',
+    'sub', 'sup', 'textarea', 'time', 'tt', 'var'
+}
+
+SEPARATORS = {'br'}
+
+
+# Definition of whitespace in HTML:
+# https://www.w3.org/TR/html4/struct/text.html#h-9.1
+WHITESPACE_RE = re.compile(u'[\x20\x09\x0C\u200B\x0A\x0D]+')
+
+
+def squash_html_whitespace(text):
+    # use raw extract_text for preformatted content (like <pre> content or set
+    # by CSS rules)
+    # apply this function on top of
+    return WHITESPACE_RE.sub(' ', text)
+
+
+def _squash_artifical_nl(parts):
+    output, last_nl = [], False
+    for x in parts:
+        if x is not None:
+            output.append(x)
+            last_nl = False
+        elif not last_nl:
+            output.append(None)
+            last_nl = True
+    return output
+
+
+def _strip_artifical_nl(parts):
+    if not parts:
+        return parts
+    for start_idx, pt in enumerate(parts):
+        if isinstance(pt, str):
+            # 0, 1, 2, index of first string [start_idx:...
+            break
+    iterator = enumerate(parts[:start_idx - 1 if start_idx > 0 else None:-1])
+    for end_idx, pt in iterator:
+        if isinstance(pt, str):  # 0=None, 1=-1, 2=-2, index of last string
+            break
+    return parts[start_idx:-end_idx if end_idx > 0 else None]
+
+
+def _merge_original_parts(parts):
+    output, orp_buf = [], []
+
+    def flush():
+        if orp_buf:
+            item = squash_html_whitespace(''.join(orp_buf)).strip()
+            if item:
+                output.append(item)
+            orp_buf[:] = []
+
+    for x in parts:
+        if not isinstance(x, str):
+            flush()
+            output.append(x)
+        else:
+            orp_buf.append(x)
+    flush()
+    return output
+
+
+def extract_text_array(dom, squash_artifical_nl=True, strip_artifical_nl=True):
+    if callable(dom.tag):
+        return ''
+    r = []
+    if dom.tag in SEPARATORS:
+        r.append(True)  # equivalent of '\n' used to designate separators
+    elif dom.tag not in INLINE_TAGS:
+        # equivalent of '\n' used to designate artifically inserted newlines
+        r.append(None)
+    if dom.text is not None:
+        r.append(dom.text)
+    for child in dom.getchildren():
+        r.extend(extract_text_array(child, squash_artifical_nl=False,
+                                    strip_artifical_nl=False))
+        if child.tail is not None:
+            r.append(child.tail)
+    if dom.tag not in INLINE_TAGS and dom.tag not in SEPARATORS:
+        # equivalent of '\n' used to designate artifically inserted newlines
+        r.append(None)
+    if squash_artifical_nl:
+        r = _squash_artifical_nl(r)
+    if strip_artifical_nl:
+        r = _strip_artifical_nl(r)
+    return r
+
+
+def extract_text(dom, block_symbol='\n', sep_symbol='\n', squash_space=True):
+    a = extract_text_array(dom, squash_artifical_nl=squash_space)
+    if squash_space:
+        a = _strip_artifical_nl(_squash_artifical_nl(_merge_original_parts(a)))
+    result = ''.join(
+        block_symbol if x is None else (
+            sep_symbol if x is True else x
+        )
+        for x in a
+    )
+    if squash_space:
+        result = result.strip()
+    return result
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..9974b3c
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,6 @@
+
+[pytest]
+filterwarnings =
+    ignore::DeprecationWarning
+doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL
+addopts = --doctest-modules --doctest-glob="*.rst" --ignore=docs/conf.py
diff --git a/setup.cfg b/setup.cfg
index b6d2f18..f7efa3f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -5,6 +5,7 @@ with-doctest = True
 doctest-extension = rst
 doctest-fixtures = _fixt
 include = docs
+exclude = seleniumtests
 cover-package = pyquery
 with-coverage = 1
 doctest-options = +ELLIPSIS,+NORMALIZE_WHITESPACE
@@ -12,5 +13,4 @@ doctest-options = +ELLIPSIS,+NORMALIZE_WHITESPACE
 [egg_info]
 tag_build = 
 tag_date = 0
-tag_svn_revision = 0
 
diff --git a/setup.py b/setup.py
index 2bbe6fe..adfb19a 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,4 @@
-#-*- coding:utf-8 -*-
+# -*- coding:utf-8 -*-
 #
 # Copyright (C) 2008 - Olivier Lauzanne <olauzanne@gmail.com>
 #
@@ -8,6 +8,12 @@ from setuptools import setup, find_packages
 import os
 
 
+install_requires = [
+    'lxml>=2.1',
+    'cssselect>0.7.9',
+]
+
+
 def read(*names):
     values = dict()
     for name in names:
@@ -34,7 +40,7 @@ News
 
 """ % read('README', 'CHANGES')
 
-version = '1.2.9'
+version = '1.4.4.dev0'
 
 setup(name='pyquery',
       version=version,
@@ -43,12 +49,10 @@ setup(name='pyquery',
       classifiers=[
           "Intended Audience :: Developers",
           "Development Status :: 5 - Production/Stable",
-          "Programming Language :: Python :: 2",
-          "Programming Language :: Python :: 2.6",
-          "Programming Language :: Python :: 2.7",
           "Programming Language :: Python :: 3",
-          "Programming Language :: Python :: 3.3",
-          "Programming Language :: Python :: 3.4",
+          "Programming Language :: Python :: 3.5",
+          "Programming Language :: Python :: 3.6",
+          "Programming Language :: Python :: 3.7",
       ],
       keywords='jquery html xml scraping',
       author='Olivier Lauzanne',
@@ -60,12 +64,12 @@ setup(name='pyquery',
       packages=find_packages(exclude=[
           'bootstrap', 'bootstrap-py3k', 'docs', 'tests', 'README_fixt'
       ]),
+      extras_require={
+        'test': ['requests', 'webob', 'webtest', 'pytest', 'pytest-cov'],
+      },
       include_package_data=True,
       zip_safe=False,
-      install_requires=[
-          'lxml>=2.1',
-          'cssselect',
-      ],
+      install_requires=install_requires,
       entry_points="""
       # -*- Entry points: -*-
       """,
diff --git a/tests/apps.py b/tests/apps.py
index ff13f97..e8b17bc 100644
--- a/tests/apps.py
+++ b/tests/apps.py
@@ -2,20 +2,19 @@
 from webob import Request
 from webob import Response
 from webob import exc
-from .compat import b
 
 
 def input_app(environ, start_response):
     resp = Response()
     req = Request(environ)
     if req.path_info == '/':
-        resp.body = b('<input name="youyou" type="text" value="" />')
+        resp.text = '<input name="youyou" type="text" value="" />'
     elif req.path_info == '/submit':
-        resp.body = b('<input type="submit" value="OK" />')
+        resp.text = '<input type="submit" value="OK" />'
     elif req.path_info.startswith('/html'):
-        resp.body = b('<html><p>Success</p></html>')
+        resp.text = '<html><p>Success</p></html>'
     else:
-        resp.body = ''
+        resp.text = '<html></html>'
     return resp(environ, start_response)
 
 
@@ -23,9 +22,9 @@ def application(environ, start_response):
     req = Request(environ)
     response = Response()
     if req.method == 'GET':
-        response.body = b('<pre>Yeah !</pre>')
+        response.text = '<pre>Yeah !</pre>'
     else:
-        response.body = b('<a href="/plop">Yeah !</a>')
+        response.text = '<a href="/plop">Yeah !</a>'
     return response(environ, start_response)
 
 
diff --git a/tests/browser_base.py b/tests/browser_base.py
new file mode 100644
index 0000000..dae4b12
--- /dev/null
+++ b/tests/browser_base.py
@@ -0,0 +1,84 @@
+
+class TextExtractionMixin():
+    def _prepare_dom(self, html):
+        self.last_html = '<html><body>' + html + '</body></html>'
+
+    def _simple_test(self, html, expected_sq, expected_nosq, **kwargs):
+        raise NotImplementedError
+
+    def test_inline_tags(self):
+        self._simple_test(
+            'Phas<em>ell</em>us<i> eget </i>sem <b>facilisis</b> justo',
+            'Phasellus eget sem facilisis justo',
+            'Phasellus eget sem facilisis justo',
+        )
+        self._simple_test(
+            'Phasellus <span> eget </span> sem <b>facilisis\n</b> justo',
+            'Phasellus eget sem facilisis justo',
+            'Phasellus  eget  sem facilisis\n justo',
+        )
+        self._simple_test(
+            ('Phasellus   <span>\n  eget\n           '
+             'sem\n\tfacilisis</span>   justo'),
+            'Phasellus eget sem facilisis justo',
+            'Phasellus   \n  eget\n           sem\n\tfacilisis   justo'
+        )
+
+    def test_block_tags(self):
+        self._simple_test(
+            'Phas<p>ell</p>us<div> eget </div>sem <h1>facilisis</h1> justo',
+            'Phas\nell\nus\neget\nsem\nfacilisis\njusto',
+            'Phas\nell\nus\n eget \nsem \nfacilisis\n justo',
+        )
+        self._simple_test(
+            '<p>In sagittis</p> <p>rutrum</p><p>condimentum</p>',
+            'In sagittis\nrutrum\ncondimentum',
+            'In sagittis\n \nrutrum\n\ncondimentum',
+        )
+        self._simple_test(
+            'In <p>\nultricies</p>\n erat et <p>\n\n\nmaximus\n\n</p> mollis',
+            'In\nultricies\nerat et\nmaximus\nmollis',
+            'In \n\nultricies\n\n erat et \n\n\n\nmaximus\n\n\n mollis',
+        )
+        self._simple_test(
+            ('Integer <div><div>\n  <div>quis commodo</div></div> '
+             '</div> libero'),
+            'Integer\nquis commodo\nlibero',
+            'Integer \n\n\n  \nquis commodo\n\n \n libero',
+        )
+        self._simple_test(
+            'Heading<ul><li>one</li><li>two</li><li>three</li></ul>',
+            'Heading\none\ntwo\nthree',
+            'Heading\n\none\n\ntwo\n\nthree',
+        )
+
+    def test_separators(self):
+        self._simple_test(
+            'Some words<br>test. Another word<br><br> <br> test.',
+            'Some words\ntest. Another word\n\n\ntest.',
+            'Some words\ntest. Another word\n\n \n test.',
+        )
+        self._simple_test(
+            'Inline <span>  splitted by\nbr<br>tag</span> test',
+            'Inline splitted by br\ntag test',
+            'Inline   splitted by\nbr\ntag test',
+        )
+        self._simple_test(
+            'Some words<hr>test. Another word<hr><hr> <hr> test.',
+            'Some words\ntest. Another word\ntest.',
+            'Some words\n\ntest. Another word\n\n\n\n \n\n test.',
+        )
+
+    def test_strip(self):
+        self._simple_test(
+            ' text\n',
+            'text',
+            ' text\n',
+        )
+
+    def test_ul_li(self):
+        self._simple_test(
+            '<ul> <li>  </li> </ul>',
+            '',
+            ' \n  \n '
+        )
diff --git a/tests/compat.py b/tests/compat.py
deleted file mode 100644
index aeacf60..0000000
--- a/tests/compat.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# -*- coding: utf-8 -*-
-import sys
-
-PY3k = sys.version_info >= (3,)
-
-if PY3k:
-    text_type = str
-
-    def u(value, encoding):
-        return str(value)
-
-    def b(value):
-        return value.encode('utf-8')
-else:
-    text_type = unicode
-
-    def u(value, encoding):  # NOQA
-        return unicode(value, encoding)
-
-    def b(value):  # NOQA
-        return str(value)
-
-try:
-    from unittest2 import TestCase
-except ImportError:
-    from unittest import TestCase  # NOQA
diff --git a/tests/geckodriver.sh b/tests/geckodriver.sh
new file mode 100755
index 0000000..c714c0f
--- /dev/null
+++ b/tests/geckodriver.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+driver="https://github.com/mozilla/geckodriver/releases/download/v0.26.0/geckodriver-v0.26.0-linux64.tar.gz"
+
+[ -f geckodriver ] || wget -cqO- $driver | tar xvzf -
diff --git a/tests/selenium.sh b/tests/selenium.sh
new file mode 100755
index 0000000..00041b3
--- /dev/null
+++ b/tests/selenium.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+# script to run selenium tests
+
+# get geckodriver
+./tests/geckodriver.sh
+
+# run tox with py3.7
+MOZ_HEADLESS=1 PATH=$PATH:$PWD tox -e py37 tests/test_real_browser.py
diff --git a/tests/test_browser.py b/tests/test_browser.py
new file mode 100644
index 0000000..d4b130f
--- /dev/null
+++ b/tests/test_browser.py
@@ -0,0 +1,17 @@
+import unittest
+
+from pyquery.pyquery import PyQuery
+from .browser_base import TextExtractionMixin
+
+
+class TestInnerText(unittest.TestCase, TextExtractionMixin):
+    def _prepare_dom(self, html):
+        super(TestInnerText, self)._prepare_dom(html)
+        self.pq = PyQuery(self.last_html)
+
+    def _simple_test(self, html, expected_sq, expected_nosq, **kwargs):
+        self._prepare_dom(html)
+        text_sq = self.pq.text(squash_space=True, **kwargs)
+        text_nosq = self.pq.text(squash_space=False, **kwargs)
+        self.assertEqual(text_sq, expected_sq)
+        self.assertEqual(text_nosq, expected_nosq)
diff --git a/tests/test_pyquery.py b/tests/test_pyquery.py
index 979d681..d82a556 100644
--- a/tests/test_pyquery.py
+++ b/tests/test_pyquery.py
@@ -1,34 +1,17 @@
-#-*- coding:utf-8 -*-
-#
 # Copyright (C) 2008 - Olivier Lauzanne <olauzanne@gmail.com>
 #
 # Distributed under the BSD license, see LICENSE.txt
 import os
 import sys
-sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+import time
 from lxml import etree
-from pyquery.pyquery import PyQuery as pq
-from pyquery.ajax import PyQuery as pqa
+from pyquery.pyquery import PyQuery as pq, no_default
+from pyquery.openers import HAS_REQUEST
 from webtest import http
 from webtest.debugapp import debug_app
-from .apps import application
-from .apps import secure_application
-from .compat import PY3k
-from .compat import u
-from .compat import b
-from .compat import text_type
-from .compat import TestCase
-
+from unittest import TestCase
 
-def not_py3k(func):
-    if not PY3k:
-        return func
-
-try:
-    import requests  # NOQA
-    HAS_REQUEST = True
-except ImportError:
-    HAS_REQUEST = False
+sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
 
 
 dirname = os.path.dirname(os.path.abspath(__file__))
@@ -40,18 +23,10 @@ path_to_invalid_file = os.path.join(dirname, 'invalid.xml')
 class TestUnicode(TestCase):
 
     def test_unicode(self):
-        xml = pq(u("<html><p>é</p></html>", 'utf-8'))
-        self.assertEqual(type(xml.html()), text_type)
-        if PY3k:
-            self.assertEqual(str(xml), '<html><p>é</p></html>')
-            self.assertEqual(str(xml('p:contains("é")')), '<p>é</p>')
-        else:
-            self.assertEqual(unicode(xml), u("<html><p>é</p></html>", 'utf-8'))
-            self.assertEqual(str(xml), '<html><p>&#233;</p></html>')
-            self.assertEqual(str(xml(u('p:contains("é")', 'utf8'))),
-                             '<p>&#233;</p>')
-            self.assertEqual(unicode(xml(u('p:contains("é")', 'utf8'))),
-                             u('<p>é</p>', 'utf8'))
+        xml = pq(u"<html><p>é</p></html>")
+        self.assertEqual(type(xml.html()), str)
+        self.assertEqual(str(xml), '<html><p>é</p></html>')
+        self.assertEqual(str(xml('p:contains("é")')), '<p>é</p>')
 
 
 class TestAttributeCase(TestCase):
@@ -102,8 +77,32 @@ class TestSelector(TestCase):
             <body>
               <form action="/">
                 <input name="enabled" type="text" value="test"/>
+                <b disabled>Not :disabled</b>
                 <input name="disabled" type="text"
                        value="disabled" disabled="disabled"/>
+                <fieldset>
+                    <input name="fieldset-enabled">
+                </fieldset>
+                <fieldset disabled>
+                    <legend>
+                        <input name="legend-enabled">
+                    </legend>
+                    <input name="fieldset-disabled">
+                    <legend>
+                        <input name="legend-disabled">
+                    </legend>
+                    <select id="disabled-select">
+                        <optgroup>
+                            <option></option>
+                        </optgroup>
+                    </select>
+                </fieldset>
+                <select>
+                    <optgroup id="disabled-optgroup" disabled>
+                        <option id="disabled-from-optgroup"></option>
+                        <option id="disabled-option" disabled></option>
+                    </optgroup>
+                </select>
                 <input name="file" type="file" />
                 <select name="select">
                   <option value="">Choose something</option>
@@ -135,15 +134,20 @@ class TestSelector(TestCase):
               <h4>Heading 4</h4>
               <h5>Heading 5</h5>
               <h6>Heading 6</h6>
+              <div></div>
             </body>
            </html>
            """
 
     def test_get_root(self):
-        doc = pq(b('<?xml version="1.0" encoding="UTF-8"?><root><p/></root>'))
+        doc = pq(b'<?xml version="1.0" encoding="UTF-8"?><root><p/></root>')
         self.assertEqual(isinstance(doc.root, etree._ElementTree), True)
         self.assertEqual(doc.encoding, 'UTF-8')
 
+        child = doc.children().eq(0)
+        self.assertNotEqual(child._parent, no_default)
+        self.assertTrue(isinstance(child.root, etree._ElementTree))
+
     def test_selector_from_doc(self):
         doc = etree.fromstring(self.html)
         assert len(self.klass(doc)) == 1
@@ -183,24 +187,35 @@ class TestSelector(TestCase):
         self.assertEqual(e('div:lt(1)').text(), 'node1')
         self.assertEqual(e('div:eq(2)').text(), 'node3')
 
-        #test on the form
+        # test on the form
         e = self.klass(self.html4)
-        assert len(e(':disabled')) == 1
-        assert len(e('input:enabled')) == 9
+        disabled = e(':disabled')
+        self.assertIn(e('[name="disabled"]')[0], disabled)
+        self.assertIn(e('fieldset[disabled]')[0], disabled)
+        self.assertIn(e('[name="legend-disabled"]')[0], disabled)
+        self.assertIn(e('[name="fieldset-disabled"]')[0], disabled)
+        self.assertIn(e('#disabled-optgroup')[0], disabled)
+        self.assertIn(e('#disabled-from-optgroup')[0], disabled)
+        self.assertIn(e('#disabled-option')[0], disabled)
+        self.assertIn(e('#disabled-select')[0], disabled)
+
+        assert len(disabled) == 8
+        assert len(e('select:enabled')) == 2
+        assert len(e('input:enabled')) == 11
         assert len(e(':selected')) == 1
         assert len(e(':checked')) == 2
         assert len(e(':file')) == 1
-        assert len(e(':input')) == 12
+        assert len(e(':input')) == 18
         assert len(e(':button')) == 2
         assert len(e(':radio')) == 3
         assert len(e(':checkbox')) == 3
 
-        #test on other elements
+        # test on other elements
         e = self.klass(self.html5)
         assert len(e(":header")) == 6
         assert len(e(":parent")) == 2
-        assert len(e(":empty")) == 6
-        assert len(e(":contains('Heading')")) == 6
+        assert len(e(":empty")) == 1
+        assert len(e(":contains('Heading')")) == 8
 
     def test_on_the_fly_dom_creation(self):
         e = self.klass(self.html)
@@ -220,6 +235,27 @@ class TestTraversal(TestCase):
            </html>
            """
 
+    html2 = """
+            <html>
+             <body>
+               <dl>
+                 <dt id="term-1">term 1</dt>
+                 <dd>definition 1-a</dd>
+                 <dd>definition 1-b</dd>
+                 <dd>definition 1-c</dd>
+                 <dd>definition 1-d</dd>
+                 <dt id="term-2">term 2</dt>
+                 <dd>definition 2-a</dd>
+                 <dd class="strange">definition 2-b</dd>
+                 <dd>definition 2-c</dd>
+                 <dt id="term-3">term 3</dt>
+                 <dd>definition 3-a</dd>
+                 <dd>definition 3-b</dd>
+               </dl>
+             </body>
+            </html>
+            """
+
     def test_filter(self):
         assert len(self.klass('div', self.html).filter('.node3')) == 1
         assert len(self.klass('div', self.html).filter('#node2')) == 1
@@ -263,6 +299,32 @@ class TestTraversal(TestCase):
                           self.html).closest('.node3').attr('id') == 'node2'
         assert self.klass('.node3', self.html).closest('form') == []
 
+    def test_next_all(self):
+        d = pq(self.html2)
+
+        # without filter
+        self.assertEqual(
+            len(d('#term-2').next_all()), 6)
+        # with filter
+        self.assertEqual(
+            len(d('#term-2').next_all('dd')), 5)
+        # when empty
+        self.assertEqual(
+            d('#NOTHING').next_all(), [])
+
+    def test_next_until(self):
+        d = pq(self.html2)
+
+        # without filter
+        self.assertEqual(
+            len(d('#term-2').next_until('dt')), 3)
+        # with filter
+        self.assertEqual(
+            len(d('#term-2').next_until('dt', ':not(.strange)')), 2)
+        # when empty
+        self.assertEqual(
+            d('#NOTHING').next_until('*'), [])
+
 
 class TestOpener(TestCase):
 
@@ -282,6 +344,12 @@ class TestOpener(TestCase):
         assert len(doc('.node')) == 1, doc
 
 
+class TestConstruction(TestCase):
+
+    def test_typeerror_on_invalid_value(self):
+        self.assertRaises(TypeError, pq, object())
+
+
 class TestComment(TestCase):
 
     def test_comment(self):
@@ -300,13 +368,17 @@ class TestCallback(TestCase):
 
     def test_S_this_inside_callback(self):
         S = pq(self.html)
-        self.assertEqual(S('li').map(lambda i, el: S(this).html()),  # NOQA
-                                     ['Coffee', 'Tea', 'Milk'])
+        self.assertEqual(S('li').map(
+            lambda i, el: S(this).html()),  # NOQA
+            ['Coffee', 'Tea', 'Milk']
+        )
 
     def test_parameterless_callback(self):
         S = pq(self.html)
-        self.assertEqual(S('li').map(lambda: S(this).html()),  # NOQA
-                                     ['Coffee', 'Tea', 'Milk'])
+        self.assertEqual(S('li').map(
+            lambda: S(this).html()),  # NOQA
+            ['Coffee', 'Tea', 'Milk']
+        )
 
 
 class TestHook(TestCase):
@@ -320,7 +392,7 @@ class TestHook(TestCase):
 
     def test_fn(self):
         "Example from `PyQuery.Fn` docs."
-        fn = lambda: this.map(lambda i, el: pq(this).outerHtml())
+        fn = lambda: this.map(lambda i, el: pq(this).outerHtml())  # NOQA
         pq.fn.listOuterHtml = fn
         S = pq(self.html)
         self.assertEqual(S('li').listOuterHtml(),
@@ -328,59 +400,13 @@ class TestHook(TestCase):
 
     def test_fn_with_kwargs(self):
         "fn() with keyword arguments."
-        pq.fn.test = lambda p=1: pq(this).eq(p)
+        pq.fn.test = lambda p=1: pq(this).eq(p)  # NOQA
         S = pq(self.html)
         self.assertEqual(S('li').test(0).text(), 'Coffee')
         self.assertEqual(S('li').test().text(), 'Tea')
         self.assertEqual(S('li').test(p=2).text(), 'Milk')
 
 
-class TestAjaxSelector(TestSelector):
-    klass = pqa
-
-    def setUp(self):
-        self.s = http.StopableWSGIServer.create(application)
-
-    @not_py3k
-    def test_proxy(self):
-        self.s.wait()
-        application_url = self.s.application_url
-        e = self.klass([])
-        val = e.get(application_url)
-        assert len(val('pre')) == 1, (str(val.response), val)
-
-    def test_get(self):
-        e = self.klass(app=application)
-        val = e.get('/')
-        assert len(val('pre')) == 1, val
-
-    def test_secure_get(self):
-        e = self.klass(app=secure_application)
-        val = e.get('/', environ=dict(REMOTE_USER='gawii'))
-        assert len(val('pre')) == 1, val
-        val = e.get('/', REMOTE_USER='gawii')
-        assert len(val('pre')) == 1, val
-
-    def test_secure_get_not_authorized(self):
-        e = self.klass(app=secure_application)
-        val = e.get('/')
-        assert len(val('pre')) == 0, val
-
-    def test_post(self):
-        e = self.klass(app=application)
-        val = e.post('/')
-        assert len(val('a')) == 1, val
-
-    def test_subquery(self):
-        e = self.klass(app=application)
-        n = e('div')
-        val = n.post('/')
-        assert len(val('a')) == 1, val
-
-    def tearDown(self):
-        self.s.shutdown()
-
-
 class TestManipulating(TestCase):
     html = '''
     <div class="portlet">
@@ -389,6 +415,78 @@ class TestManipulating(TestCase):
     </div>
     '''
 
+    html2 = '''
+        <input name="spam" value="Spam">
+        <input name="eggs" value="Eggs">
+        <input type="checkbox" value="Bacon">
+        <input type="radio" value="Ham">
+    '''
+
+    html2_newline = '''
+        <input id="newline-text" type="text" name="order" value="S
+pam">
+        <input id="newline-radio" type="radio" name="order" value="S
+pam">
+    '''
+
+    html3 = '''
+        <textarea id="textarea-single">Spam</textarea>
+        <textarea id="textarea-multi">Spam
+<b>Eggs</b>
+Bacon</textarea>
+    '''
+
+    html4 = '''
+        <select id="first">
+            <option value="spam">Spam</option>
+            <option value="eggs">Eggs</option>
+        </select>
+        <select id="second">
+            <option value="spam">Spam</option>
+            <option value="eggs" selected>Eggs</option>
+            <option value="bacon">Bacon</option>
+        </select>
+        <select id="third">
+        </select>
+        <select id="fourth">
+            <option value="spam">Spam</option>
+            <option value="spam">Eggs</option>
+            <option value="spam">Bacon</option>
+        </select>
+    '''
+
+    html6 = '''
+        <select id="first" multiple>
+            <option value="spam" selected>Spam</option>
+            <option value="eggs" selected>Eggs</option>
+            <option value="bacon">Bacon</option>
+        </select>
+        <select id="second" multiple>
+            <option value="spam">Spam</option>
+            <option value="eggs">Eggs</option>
+            <option value="bacon">Bacon</option>
+        </select>
+        <select id="third" multiple>
+            <option value="spam">Spam</option>
+            <option value="spam">Eggs</option>
+            <option value="spam">Bacon</option>
+        </select>
+    '''
+
+    html5 = '''
+        <div>
+            <input id="first" value="spam">
+            <input id="second" value="eggs">
+            <textarea id="third">bacon</textarea>
+        </div>
+    '''
+
+    def test_attr_empty_string(self):
+        d = pq('<div>')
+        d.attr('value', '')
+        self.assertEqual(d.outer_html(), '<div value=""></div>')
+        self.assertEqual(d.outer_html(method="xml"), '<div value=""/>')
+
     def test_remove(self):
         d = pq(self.html)
         d('img').remove()
@@ -402,6 +500,262 @@ class TestManipulating(TestCase):
         d.removeClass('xx')
         assert 'class' not in str(d), str(d)
 
+    def test_val_for_inputs(self):
+        d = pq(self.html2)
+        self.assertIsNone(d('input[name="none"]').val())
+        self.assertEqual(d('input[name="spam"]').val(), 'Spam')
+        self.assertEqual(d('input[name="eggs"]').val(), 'Eggs')
+        self.assertEqual(d('input:checkbox').val(), 'Bacon')
+        self.assertEqual(d('input:radio').val(), 'Ham')
+        d('input[name="spam"]').val('42')
+        d('input[name="eggs"]').val('43')
+        d('input:checkbox').val('44')
+        d('input:radio').val('45')
+        self.assertEqual(d('input[name="spam"]').val(), '42')
+        self.assertEqual(d('input[name="eggs"]').val(), '43')
+        self.assertEqual(d('input:checkbox').val(), '44')
+        self.assertEqual(d('input:radio').val(), '45')
+
+    def test_val_for_inputs_with_newline(self):
+        d = pq(self.html2_newline)
+        self.assertEqual(d('#newline-text').val(), 'Spam')
+        self.assertEqual(d('#newline-radio').val(), 'S\npam')
+
+    def test_val_for_textarea(self):
+        d = pq(self.html3)
+        self.assertEqual(d('#textarea-single').val(), 'Spam')
+        self.assertEqual(d('#textarea-single').text(), 'Spam')
+        d('#textarea-single').val('42')
+        self.assertEqual(d('#textarea-single').val(), '42')
+        # Note: jQuery still returns 'Spam' here.
+        self.assertEqual(d('#textarea-single').text(), '42')
+
+        multi_expected = '''Spam\n<b>Eggs</b>\nBacon'''
+        self.assertEqual(d('#textarea-multi').val(), multi_expected)
+        self.assertEqual(d('#textarea-multi').text(), multi_expected)
+        multi_new = '''Bacon\n<b>Eggs</b>\nSpam'''
+        d('#textarea-multi').val(multi_new)
+        self.assertEqual(d('#textarea-multi').val(), multi_new)
+        self.assertEqual(d('#textarea-multi').text(), multi_new)
+
+    def test_val_for_select(self):
+        d = pq(self.html4)
+        self.assertEqual(d('#first').val(), 'spam')
+        self.assertEqual(d('#second').val(), 'eggs')
+        self.assertIsNone(d('#third').val())
+        d('#first').val('eggs')
+        d('#second').val('bacon')
+        d('#third').val('eggs')  # Selecting non-existing option.
+        self.assertEqual(d('#first').val(), 'eggs')
+        self.assertEqual(d('#second').val(), 'bacon')
+        self.assertIsNone(d('#third').val())
+        d('#first').val('bacon')  # Selecting non-existing option.
+        self.assertEqual(d('#first').val(), 'spam')
+        # Value set based on option order, not value order
+        d('#second').val(['bacon', 'eggs'])
+        self.assertEqual(d('#second').val(), 'eggs')
+        d('#fourth').val(['spam'])
+        self.assertEqual(d('#fourth').val(), 'spam')
+        # Sets first option with matching value
+        self.assertEqual(d('#fourth option[selected]').length, 1)
+        self.assertEqual(d('#fourth option[selected]').text(), 'Spam')
+
+    def test_val_for_select_multiple(self):
+        d = pq(self.html6)
+        self.assertEqual(d('#first').val(), ['spam', 'eggs'])
+        # Selecting non-existing option.
+        d('#first').val(['eggs', 'sausage', 'bacon'])
+        self.assertEqual(d('#first').val(), ['eggs', 'bacon'])
+        self.assertEqual(d('#second').val(), [])
+        d('#second').val('eggs')
+        self.assertEqual(d('#second').val(), ['eggs'])
+        d('#second').val(['not spam', 'not eggs'])
+        self.assertEqual(d('#second').val(), [])
+        d('#third').val(['spam'])
+        self.assertEqual(d('#third').val(), ['spam', 'spam', 'spam'])
+
+    def test_val_for_input_and_textarea_given_array_value(self):
+        d = pq('<input type="text">')
+        d('input').val(['spam', 'eggs'])
+        self.assertEqual(d('input').val(), 'spam,eggs')
+        d = pq('<textarea></textarea>')
+        d('textarea').val(['spam', 'eggs'])
+        self.assertEqual(d('textarea').val(), 'spam,eggs')
+
+    def test_val_for_multiple_elements(self):
+        d = pq(self.html5)
+        # "Get" returns *first* value.
+        self.assertEqual(d('div > *').val(), 'spam')
+        # "Set" updates *every* value.
+        d('div > *').val('42')
+        self.assertEqual(d('#first').val(), '42')
+        self.assertEqual(d('#second').val(), '42')
+        self.assertEqual(d('#third').val(), '42')
+
+    def test_val_checkbox_no_value_attribute(self):
+        d = pq('<input type="checkbox">')
+        self.assertEqual(d.val(), 'on')
+        d = pq('<input type="checkbox" value="">')
+        self.assertEqual(d.val(), '')
+
+    def test_val_radio_no_value_attribute(self):
+        d = pq('<input type="radio">')
+        self.assertEqual(d.val(), 'on')
+
+    def test_val_value_is_empty_string(self):
+        d = pq('<input value="">')
+        self.assertEqual(d.val(), '')
+
+    def test_val_input_has_no_value_attr(self):
+        d = pq('<input>')
+        self.assertEqual(d.val(), '')
+
+    def test_html_replacement(self):
+        html = '<div>Not Me<span>Replace Me</span>Not Me</div>'
+        replacement = 'New <em>Contents</em> New'
+        expected = html.replace('Replace Me', replacement)
+
+        d = pq(html)
+        d.find('span').html(replacement)
+
+        new_html = d.outerHtml()
+        self.assertEqual(new_html, expected)
+        self.assertIn(replacement, new_html)
+
+
+class TestAjax(TestCase):
+
+    html = '''
+    <div id="div">
+    <input form="dispersed" name="order" value="spam">
+    </div>
+    <form id="dispersed">
+    <div><input name="order" value="eggs"></div>
+    <input form="dispersed" name="order" value="ham">
+    <input form="other-form" name="order" value="nothing">
+    <input form="" name="order" value="nothing">
+    </form>
+    <form id="other-form">
+    <input form="dispersed" name="order" value="tomato">
+    </form>
+    <form class="no-id">
+    <input form="dispersed" name="order" value="baked beans">
+    <input name="spam" value="Spam">
+    </form>
+    '''
+
+    html2 = '''
+    <form id="first">
+    <input name="order" value="spam">
+    <fieldset>
+    <input name="fieldset" value="eggs">
+    <input id="input" name="fieldset" value="ham">
+    </fieldset>
+    </form>
+    <form id="datalist">
+    <datalist><div><input name="datalist" value="eggs"></div></datalist>
+    <input type="checkbox" name="checkbox" checked>
+    <input type="radio" name="radio" checked>
+    </form>
+    '''
+
+    html3 = '''
+    <form>
+    <input name="order" value="spam">
+    <input id="noname" value="sausage">
+    <fieldset disabled>
+    <input name="order" value="sausage">
+    </fieldset>
+    <input name="disabled" value="ham" disabled>
+    <input type="submit" name="submit" value="Submit">
+    <input type="button" name="button" value="">
+    <input type="image" name="image" value="">
+    <input type="reset" name="reset" value="Reset">
+    <input type="file" name="file" value="">
+    <button type="submit" name="submit" value="submit"></button>
+    <input type="checkbox" name="spam">
+    <input type="radio" name="eggs">
+    </form>
+    '''
+
+    html4 = '''
+    <form>
+    <input name="spam" value="Spam/
+spam">
+    <select name="order" multiple>
+    <option value="baked
+beans" selected>
+    <option value="tomato" selected>
+    <option value="spam">
+    </select>
+    <textarea name="multiline">multiple
+lines
+of text</textarea>
+    </form>
+    '''
+
+    def test_serialize_pairs_form_id(self):
+        d = pq(self.html)
+        self.assertEqual(d('#div').serialize_pairs(), [])
+        self.assertEqual(d('#dispersed').serialize_pairs(), [
+            ('order', 'spam'), ('order', 'eggs'), ('order', 'ham'),
+            ('order', 'tomato'), ('order', 'baked beans'),
+        ])
+        self.assertEqual(d('.no-id').serialize_pairs(), [
+            ('spam', 'Spam'),
+        ])
+
+    def test_serialize_pairs_form_controls(self):
+        d = pq(self.html2)
+        self.assertEqual(d('fieldset').serialize_pairs(), [
+            ('fieldset', 'eggs'), ('fieldset', 'ham'),
+        ])
+        self.assertEqual(d('#input, fieldset, #first').serialize_pairs(), [
+            ('order', 'spam'), ('fieldset', 'eggs'), ('fieldset', 'ham'),
+            ('fieldset', 'eggs'), ('fieldset', 'ham'), ('fieldset', 'ham'),
+        ])
+        self.assertEqual(d('#datalist').serialize_pairs(), [
+            ('datalist', 'eggs'), ('checkbox', 'on'), ('radio', 'on'),
+        ])
+
+    def test_serialize_pairs_filter_controls(self):
+        d = pq(self.html3)
+        self.assertEqual(d('form').serialize_pairs(), [
+            ('order', 'spam')
+        ])
+
+    def test_serialize_pairs_form_values(self):
+        d = pq(self.html4)
+        self.assertEqual(d('form').serialize_pairs(), [
+            ('spam', 'Spam/spam'), ('order', 'baked\r\nbeans'),
+            ('order', 'tomato'), ('multiline', 'multiple\r\nlines\r\nof text'),
+        ])
+
+    def test_serialize_array(self):
+        d = pq(self.html4)
+        self.assertEqual(d('form').serialize_array(), [
+            {'name': 'spam', 'value': 'Spam/spam'},
+            {'name': 'order', 'value': 'baked\r\nbeans'},
+            {'name': 'order', 'value': 'tomato'},
+            {'name': 'multiline', 'value': 'multiple\r\nlines\r\nof text'},
+        ])
+
+    def test_serialize(self):
+        d = pq(self.html4)
+        self.assertEqual(
+            d('form').serialize(),
+            'spam=Spam%2Fspam&order=baked%0D%0Abeans&order=tomato&'
+            'multiline=multiple%0D%0Alines%0D%0Aof%20text'
+        )
+
+    def test_serialize_dict(self):
+        d = pq(self.html4)
+        self.assertEqual(d('form').serialize_dict(), {
+            'spam': 'Spam/spam',
+            'order': ['baked\r\nbeans', 'tomato'],
+            'multiline': 'multiple\r\nlines\r\nof text',
+        })
+
 
 class TestMakeLinks(TestCase):
 
@@ -436,14 +790,6 @@ class TestHTMLParser(TestCase):
         d = pq(self.xml, parser='html')
         d.after(self.html)  # this should not fail
 
-    @not_py3k
-    def test_soup_parser(self):
-        d = pq('<meta><head><title>Hello</head><body onload=crash()>Hi all<p>',
-               parser='soup')
-        self.assertEqual(str(d), (
-            '<html><meta/><head><title>Hello</title></head>'
-            '<body onload="crash()">Hi all<p/></body></html>'))
-
     def test_replaceWith(self):
         expected = '''<div class="portlet">
       <a href="/toto">TestimageMy link text</a>
@@ -472,6 +818,9 @@ class TestXMLNamespace(TestCase):
     <foo xmlns:bar="http://example.com/bar">
     <bar:blah>What</bar:blah>
     <idiot>123</idiot>
+    <baz xmlns="http://example.com/baz" a="b">
+          <subbaz/>
+    </baz>
     </foo>'''
 
     xhtml = '''
@@ -481,17 +830,20 @@ class TestXMLNamespace(TestCase):
     </body>
     </html>'''
 
+    namespaces = {'bar': 'http://example.com/bar',
+                  'baz': 'http://example.com/baz'}
+
     def test_selector(self):
         expected = 'What'
-        d = pq(b(self.xml), parser='xml')
+        d = pq(self.xml.encode('utf8'), parser='xml')
         val = d('bar|blah',
-                namespaces={'bar': 'http://example.com/bar'}).text()
+                namespaces=self.namespaces).text()
         self.assertEqual(repr(val), repr(expected))
 
     def test_selector_with_xml(self):
         expected = 'What'
-        d = pq('bar|blah', b(self.xml), parser='xml',
-               namespaces={'bar': 'http://example.com/bar'})
+        d = pq('bar|blah', self.xml.encode('utf8'), parser='xml',
+               namespaces=self.namespaces)
         val = d.text()
         self.assertEqual(repr(val), repr(expected))
 
@@ -503,7 +855,7 @@ class TestXMLNamespace(TestCase):
 
     def test_xhtml_namespace(self):
         expected = 'What'
-        d = pq(b(self.xhtml), parser='xml')
+        d = pq(self.xhtml.encode('utf8'), parser='xml')
         d.xhtml_to_html()
         val = d('div').text()
         self.assertEqual(repr(val), repr(expected))
@@ -517,10 +869,22 @@ class TestXMLNamespace(TestCase):
 
     def test_remove_namespaces(self):
         expected = 'What'
-        d = pq(b(self.xml), parser='xml').remove_namespaces()
+        d = pq(self.xml.encode('utf8'), parser='xml').remove_namespaces()
         val = d('blah').text()
         self.assertEqual(repr(val), repr(expected))
 
+    def test_persistent_namespaces(self):
+        d = pq(self.xml.encode('utf8'), parser='xml',
+               namespaces=self.namespaces)
+        val = d('bar|blah').text()
+        self.assertEqual(repr(val), repr('What'))
+
+    def test_namespace_traversal(self):
+        d = pq(self.xml.encode('utf8'), parser='xml',
+               namespaces=self.namespaces)
+        val = d('baz|subbaz').closest('baz|baz').attr('a')
+        self.assertEqual(repr(val), repr('b'))
+
 
 class TestWebScrapping(TestCase):
 
@@ -542,6 +906,17 @@ class TestWebScrapping(TestCase):
         self.assertIn('REQUEST_METHOD: POST', d('p').text())
         self.assertIn('q=foo', d('p').text())
 
+    def test_session(self):
+        if HAS_REQUEST:
+            import requests
+            session = requests.Session()
+            session.headers.update({'X-FOO': 'bar'})
+            d = pq(self.application_url, {'q': 'foo'},
+                   method='get', session=session)
+            self.assertIn('HTTP_X_FOO: bar', d('p').text())
+        else:
+            self.skipTest('no requests library')
+
     def tearDown(self):
         self.s.shutdown()
 
@@ -549,10 +924,27 @@ class TestWebScrapping(TestCase):
 class TestWebScrappingEncoding(TestCase):
 
     def test_get(self):
-        if not HAS_REQUEST:
-            return
-        d = pq(u('http://ru.wikipedia.org/wiki/Заглавная_страница', 'utf8'),
+        d = pq(u'http://ru.wikipedia.org/wiki/Заглавная_страница',
                method='get')
         print(d)
-        self.assertEqual(d('#n-mainpage a').text(),
-                         u('Заглавная страница', 'utf8'))
+        self.assertEqual(d('#pt-login').text(), u'Войти')
+
+
+class TestWebScrappingTimeouts(TestCase):
+
+    def setUp(self):
+        def app(environ, start_response):
+            start_response('200 OK', [('Content-Type', 'text/plain')])
+            time.sleep(2)
+            return [b'foobar\n']
+        self.s = http.StopableWSGIServer.create(app)
+        self.s.wait()
+        self.application_url = self.s.application_url.rstrip('/')
+
+    def test_get(self):
+        pq(self.application_url)
+        with self.assertRaises(Exception):
+            pq(self.application_url, timeout=1)
+
+    def tearDown(self):
+        self.s.shutdown()
diff --git a/tests/test_real_browser.py b/tests/test_real_browser.py
new file mode 100644
index 0000000..08185f7
--- /dev/null
+++ b/tests/test_real_browser.py
@@ -0,0 +1,118 @@
+import os
+import unittest
+from threading import Thread
+from time import sleep
+
+from .browser_base import TextExtractionMixin
+
+SELENIUM = 'MOZ_HEADLESS' in os.environ
+
+try:
+    from selenium import webdriver
+    from selenium.webdriver.firefox.options import Options
+except ImportError:
+    SELENIUM = False
+
+if SELENIUM:
+    from urllib.parse import urlunsplit
+    from http.server import HTTPServer, BaseHTTPRequestHandler
+    from queue import Queue
+
+    class BaseTestRequestHandler(BaseHTTPRequestHandler):
+        _last_html = ''
+
+        def _get_last_html(self):
+            q = self.server.html_queue
+            while not q.empty():
+                self._last_html = q.get_nowait()
+            return self._last_html
+
+        def log_request(self, code='-', size='-'):
+            pass
+
+        def recv_from_testsuite(self, non_blocking=False):
+            q = self.server.in_queue
+            if non_blocking:
+                return None if q.empty() else q.get_nowait()
+            return q.get()
+
+        def send_to_testsuite(self, value):
+            self.server.out_queue.put(value)
+
+    class HTMLSnippetSender(BaseTestRequestHandler):
+        last_html = b''
+
+        def get_last_html(self):
+            while True:
+                value = self.recv_from_testsuite(non_blocking=True)
+                if value is None:
+                    break
+                self.last_html = value
+            return self.last_html
+
+        def do_GET(self):
+            if self.path == '/':
+                self.send_response(200)
+                self.send_header('Content-Type', 'text/html; charset=utf-8')
+                self.end_headers()
+                self.wfile.write(self.get_last_html().encode('utf-8'))
+            else:
+                self.send_response(404)
+                self.end_headers()
+
+    class BaseBrowserTest(unittest.TestCase):
+        LOCAL_IP = '127.0.0.1'
+        PORT = 28546
+        # descendant of BaseBrowserTestRequestHandler
+        REQUEST_HANDLER_CLASS = None
+
+        @classmethod
+        def setUpClass(cls):
+            cls.to_server_queue = Queue()
+            cls.from_server_queue = Queue()
+            cls.server = HTTPServer((cls.LOCAL_IP, cls.PORT),
+                                    cls.REQUEST_HANDLER_CLASS)
+            cls.server.in_queue = cls.to_server_queue
+            cls.server.out_queue = cls.from_server_queue
+            cls.server_thread = Thread(target=cls.server.serve_forever)
+            cls.server_thread.daemon = True
+            cls.server_thread.start()
+            options = Options()
+            options.add_argument('-headless')
+            cls.driver = webdriver.Firefox(options=options)
+            sleep(1)
+
+        @classmethod
+        def tearDownClass(cls):
+            cls.driver.quit()
+            cls.server.shutdown()
+            cls.server.server_close()
+
+        def send_to_server(self, value):
+            self.to_server_queue.put(value)
+
+        def recv_from_server(self, non_blocking=False):
+            q = self.from_server_queue
+            if non_blocking:
+                return None if q.empty() else q.get_nowait()
+            return q.get()
+
+        def open_url(self, path):
+            self.driver.get(urlunsplit(
+                ('http', '{}:{}'.format(
+                    self.LOCAL_IP, self.PORT), path, '', '')))
+
+    class TestInnerText(BaseBrowserTest, TextExtractionMixin):
+        REQUEST_HANDLER_CLASS = HTMLSnippetSender
+
+        def _simple_test(self, html, expected_sq, expected_nosq, **kwargs):
+            self.send_to_server(html)
+            self.open_url('/')
+
+            selenium_text = self.driver.find_element_by_tag_name('body').text
+            self.assertEqual(selenium_text, expected_sq)
+
+            #  inner_text = self.driver.execute_script(
+            #    'return document.body.innerText')
+            #  text_content = self.driver.execute_script(
+            #    'return document.body.textContent')
diff --git a/tox.ini b/tox.ini
index 9dc356f..8ff86a2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,40 +1,42 @@
 [tox]
-envlist=py26,py27,py33,py34
+envlist=py35,py36,py37,py38
 
 [testenv]
+whitelist_externals=
+    rm
+passenv=
+    MOZ_HEADLESS
 commands =
-    {envbindir}/nosetests []
+    pytest []
 deps =
-    cssselect>0.7.9
-    requests
-    WebOb>1.1.9
-    WebTest
-    nose
-    coverage
-    unittest2
-    BeautifulSoup
-    restkit
+    py38: selenium
+    -e .[test]
 
-[testenv:py33]
-changedir={toxinidir}
+[testenv:flake8]
+skipsdist=true
+skip_install=true
+basepython = python3.8
 commands =
-    {envbindir}/nosetests []
+    flake8 pyquery tests
 deps =
-    cssselect>0.7.9
-    requests
-    WebOb>1.1.9
-    WebTest
-    nose
-    coverage
+    flake8
 
-[testenv:py34]
-changedir={toxinidir}
-commands =
-    {envbindir}/nosetests []
+[testenv:docs]
+skip_install=false
+skipsdist=true
+basepython = python3.8
+changedir = docs
 deps =
-    cssselect>0.7.9
-    requests
-    WebOb>1.1.9
-    WebTest
-    nose
-    coverage
+    sphinx
+    Pygments
+commands =
+    rm -Rf {envtmpdir}/doctrees {envtmpdir}/html
+    sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
+
+# [testenv:selenium]
+# basepython = python3.5
+# deps =
+#     selenium
+# commands =
+#     {envbindir}/python -m unittest seleniumtests.offline
+#     {envbindir}/python -m unittest seleniumtests.browser