Code

Ticket #2879: 2879.selenium-support.7.diff

File 2879.selenium-support.7.diff, 32.1 KB (added by julien, 2 years ago)
Line 
1diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
2index 0aee63d..da9d030 100644
3--- a/django/conf/global_settings.py
4+++ b/django/conf/global_settings.py
5@@ -565,6 +565,10 @@ DEFAULT_EXCEPTION_REPORTER_FILTER = 'django.views.debug.SafeExceptionReporterFil
6 # The name of the class to use to run the test suite
7 TEST_RUNNER = 'django.test.simple.DjangoTestSuiteRunner'
8 
9+# For the live test server (e.g. used for running Selenium tests)
10+LIVE_TEST_SERVER_HOST = 'localhost'
11+LIVE_TEST_SERVER_PORT = 8081
12+
13 ############
14 # FIXTURES #
15 ############
16diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py
17new file mode 100644
18index 0000000..8a09604
19--- /dev/null
20+++ b/django/contrib/admin/tests.py
21@@ -0,0 +1,44 @@
22+from django.test import LiveServerTestCase
23+from django.utils.importlib import import_module
24+from django.utils.unittest import SkipTest
25+
26+class AdminSeleniumWebDriverTestCase(LiveServerTestCase):
27+
28+    webdriver_class = 'selenium.webdriver.firefox.webdriver.WebDriver'
29+
30+    def setUp(self):
31+        try:
32+            # Import and start the WebDriver class.
33+            module, attr = self.webdriver_class.rsplit('.', 1)
34+            mod = import_module(module)
35+            WebDriver = getattr(mod, attr)
36+            self.selenium = WebDriver()
37+        except Exception:
38+            raise SkipTest('Selenium webdriver "%s" not installed or not '
39+                           'operational.' % self.webdriver_class)
40+        super(AdminSeleniumWebDriverTestCase, self).setUp()
41+
42+    def tearDown(self):
43+        super(AdminSeleniumWebDriverTestCase, self).tearDown()
44+        if hasattr(self, 'selenium'):
45+            self.selenium.quit()
46+
47+    def admin_login(self, username, password, login_url='/admin/'):
48+        """
49+        Helper function to log into the admin.
50+        """
51+        self.selenium.get('%s%s' % (self.live_server_url, login_url))
52+        username_input = self.selenium.find_element_by_name("username")
53+        username_input.send_keys(username)
54+        password_input = self.selenium.find_element_by_name("password")
55+        password_input.send_keys(password)
56+        self.selenium.find_element_by_xpath('//input[@value="Log in"]').click()
57+
58+    def get_css_value(self, selector, attribute):
59+        """
60+        Helper function that returns the value for the CSS attribute of an
61+        DOM element specified by the given selector. Uses the jQuery that ships
62+        with Django.
63+        """
64+        return self.selenium.execute_script(
65+            'return django.jQuery("%s").css("%s")' % (selector, attribute))
66\ No newline at end of file
67diff --git a/django/test/__init__.py b/django/test/__init__.py
68index a3a03e3..21a4841 100644
69--- a/django/test/__init__.py
70+++ b/django/test/__init__.py
71@@ -4,5 +4,6 @@ Django Unit Test and Doctest framework.
72 
73 from django.test.client import Client, RequestFactory
74 from django.test.testcases import (TestCase, TransactionTestCase,
75-        SimpleTestCase, skipIfDBFeature, skipUnlessDBFeature)
76+    SimpleTestCase, LiveServerTestCase, skipIfDBFeature,
77+    skipUnlessDBFeature)
78 from django.test.utils import Approximate
79diff --git a/django/test/testcases.py b/django/test/testcases.py
80index ee22ac2..4e69b48 100644
81--- a/django/test/testcases.py
82+++ b/django/test/testcases.py
83@@ -5,12 +5,19 @@ import sys
84 from functools import wraps
85 from urlparse import urlsplit, urlunsplit
86 from xml.dom.minidom import parseString, Node
87+import select
88+import socket
89+import threading
90 
91 from django.conf import settings
92+from django.contrib.staticfiles.handlers import StaticFilesHandler
93 from django.core import mail
94 from django.core.exceptions import ValidationError
95+from django.core.handlers.wsgi import WSGIHandler
96 from django.core.management import call_command
97 from django.core.signals import request_started
98+from django.core.servers.basehttp import (WSGIRequestHandler, WSGIServer,
99+    WSGIServerException)
100 from django.core.urlresolvers import clear_url_caches
101 from django.core.validators import EMPTY_VALUES
102 from django.db import (transaction, connection, connections, DEFAULT_DB_ALIAS,
103@@ -23,6 +30,7 @@ from django.test.utils import (get_warnings_state, restore_warnings_state,
104     override_settings)
105 from django.utils import simplejson, unittest as ut2
106 from django.utils.encoding import smart_str
107+from django.views.static import serve
108 
109 __all__ = ('DocTestRunner', 'OutputChecker', 'TestCase', 'TransactionTestCase',
110            'SimpleTestCase', 'skipIfDBFeature', 'skipUnlessDBFeature')
111@@ -732,3 +740,165 @@ def skipUnlessDBFeature(feature):
112     """
113     return _deferredSkip(lambda: not getattr(connection.features, feature),
114                          "Database doesn't support feature %s" % feature)
115+
116+class QuietWSGIRequestHandler(WSGIRequestHandler):
117+    """
118+    Just a regular WSGIRequestHandler except it doesn't log to the standard
119+    output any of the requests received, so as to not clutter the output for
120+    the tests' results.
121+    """
122+    def log_message(*args):
123+        pass
124+
125+class StoppableWSGIServer(WSGIServer):
126+    """
127+    The code in this class is borrowed from the `SocketServer.BaseServer` class
128+    in Python 2.6. The important functionality here is that the server is non-
129+    blocking and that it can be shut down at any moment. This is made possible
130+    by the server regularly polling the socket and checking if it has been
131+    asked to stop.
132+    Note for the future: Once Django stops supporting Python 2.5, this class
133+    can be removed as `WSGIServer` will have this ability to shutdown on
134+    demand.
135+    """
136+
137+    def __init__(self, *args, **kwargs):
138+        super(StoppableWSGIServer, self).__init__(*args, **kwargs)
139+        self.__is_shut_down = threading.Event()
140+        self.__serving = False
141+
142+    def serve_forever(self, poll_interval=0.5):
143+        """Handle one request at a time until shutdown.
144+
145+        Polls for shutdown every poll_interval seconds.
146+        """
147+        self.__serving = True
148+        self.__is_shut_down.clear()
149+        while self.__serving:
150+            r, w, e = select.select([self], [], [], poll_interval)
151+            if r:
152+                self._handle_request_noblock()
153+        self.__is_shut_down.set()
154+
155+    def shutdown(self):
156+        """Stops the serve_forever loop.
157+
158+        Blocks until the loop has finished. This must be called while
159+        serve_forever() is running in another thread, or it will
160+        deadlock.
161+        """
162+        self.__serving = False
163+        self.__is_shut_down.wait()
164+
165+    def handle_request(self):
166+        """Handle one request, possibly blocking.
167+        """
168+        fd_sets = select.select([self], [], [], None)
169+        if not fd_sets[0]:
170+            return
171+        self._handle_request_noblock()
172+
173+    def _handle_request_noblock(self):
174+        """Handle one request, without blocking.
175+
176+        I assume that select.select has returned that the socket is
177+        readable before this function was called, so there should be
178+        no risk of blocking in get_request().
179+        """
180+        try:
181+            request, client_address = self.get_request()
182+        except socket.error:
183+            return
184+        if self.verify_request(request, client_address):
185+            try:
186+                self.process_request(request, client_address)
187+            except Exception:
188+                self.handle_error(request, client_address)
189+                self.close_request(request)
190+
191+class MediaFilesHandler(StaticFilesHandler):
192+    """
193+    Handler for serving the media files.
194+    """
195+
196+    def get_base_dir(self):
197+        return settings.MEDIA_ROOT
198+
199+    def get_base_url(self):
200+        return settings.MEDIA_URL
201+
202+    def serve(self, request):
203+        return serve(request, self.file_path(request.path),
204+            document_root=self.get_base_dir())
205+
206+class LiveServerThread(threading.Thread):
207+    """
208+    Thread for running a live http server while the tests are running.
209+    """
210+
211+    def __init__(self, address, port):
212+        self.address = address
213+        self.port = port
214+        self.is_ready = threading.Event()
215+        self.error = None
216+        super(LiveServerThread, self).__init__()
217+
218+    def run(self):
219+        """
220+        Sets up live server and database and loops over handling http requests.
221+        """
222+        try:
223+            # Instantiate and start the server
224+            self.httpd = StoppableWSGIServer(
225+                (self.address, self.port), QuietWSGIRequestHandler)
226+            handler = StaticFilesHandler(MediaFilesHandler(WSGIHandler()))
227+            self.httpd.set_app(handler)
228+            self.is_ready.set()
229+            self.httpd.serve_forever()
230+        except WSGIServerException, e:
231+            self.error = e
232+            self.is_ready.set()
233+
234+    def join(self, timeout=None):
235+        self.httpd.shutdown()
236+        self.httpd.server_close()
237+        super(LiveServerThread, self).join(timeout)
238+
239+class LiveServerTestCase(TransactionTestCase):
240+    """
241+    Does basically the same as TransactionTestCase but also launches a live
242+    http server in a separate thread so that the tests may use another testing
243+    framework, such as Selenium for example, instead of the built-in dummy
244+    client.
245+    """
246+
247+    @property
248+    def live_server_url(self):
249+        return 'http://%s:%s' % (settings.LIVE_TEST_SERVER_HOST,
250+                                 settings.LIVE_TEST_SERVER_PORT)
251+
252+    def setUp(self):
253+        for conn in connections.all():
254+            if (conn.settings_dict['ENGINE'] == 'django.db.backends.sqlite3'
255+                and conn.settings_dict['NAME'] == ':memory:'):
256+                raise ut2.SkipTest('In-memory sqlite databases are not '
257+                    'supported by LiveServerTestCase because the memory '
258+                    'cannot be shared between threads.')
259+
260+        # Launch the Django live server's thread
261+        self.server_thread = LiveServerThread(
262+            settings.LIVE_TEST_SERVER_HOST,
263+            int(settings.LIVE_TEST_SERVER_PORT))
264+        self.server_thread.start()
265+
266+        # Wait for the Django server to be ready
267+        self.server_thread.is_ready.wait()
268+        if self.server_thread.error:
269+            raise self.server_thread.error
270+
271+        super(LiveServerTestCase, self).setUp()
272+
273+    def tearDown(self):
274+        # Terminate the Django server's thread
275+        self.server_thread.join()
276+        super(LiveServerTestCase, self).tearDown()
277diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt
278index 5ec09fe..baa20a1 100644
279--- a/docs/internals/contributing/writing-code/unit-tests.txt
280+++ b/docs/internals/contributing/writing-code/unit-tests.txt
281@@ -122,6 +122,19 @@ Going beyond that, you can specify an individual test method like this:
282 
283     ./runtests.py --settings=path.to.settings i18n.TranslationTests.test_lazy_objects
284 
285+Running the Selenium tests
286+~~~~~~~~~~~~~~~~~~~~~~~~~~
287+
288+Some admin tests require Selenium 2 to work via a real Web browser. To allow
289+those tests to run and not be skipped, you must install the selenium_ package
290+(version > 2.13) into your Python path.
291+
292+Then, run the tests normally, for example:
293+
294+.. code-block:: bash
295+
296+    ./runtests.py --settings=test_sqlite admin_inlines
297+
298 Running all the tests
299 ~~~~~~~~~~~~~~~~~~~~~
300 
301@@ -135,6 +148,7 @@ dependencies:
302 *  setuptools_
303 *  memcached_, plus a :ref:`supported Python binding <memcached>`
304 *  gettext_ (:ref:`gettext_on_windows`)
305+*  selenium_
306 
307 If you want to test the memcached cache backend, you'll also need to define
308 a :setting:`CACHES` setting that points at your memcached instance.
309@@ -149,6 +163,7 @@ associated tests will be skipped.
310 .. _setuptools: http://pypi.python.org/pypi/setuptools/
311 .. _memcached: http://www.danga.com/memcached/
312 .. _gettext: http://www.gnu.org/software/gettext/manual/gettext.html
313+.. _selenium: http://pypi.python.org/pypi/selenium
314 
315 .. _contrib-apps:
316 
317diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
318index a35d99a..89f2c47 100644
319--- a/docs/ref/settings.txt
320+++ b/docs/ref/settings.txt
321@@ -195,6 +195,30 @@ all cache keys used by the Django server.
322 
323 See the :ref:`cache documentation <cache_key_prefixing>` for more information.
324 
325+.. setting:: LIVE_TEST_SERVER_HOST
326+
327+LIVE_TEST_SERVER_HOST
328+~~~~~~~~~~~~~~~~~~~~~
329+
330+Default: ``'localhost'``
331+
332+Controls the host address at which the live test server gets started when using
333+a :class:`~django.test.LiveServerTestCase`.
334+
335+See also: :setting:`LIVE_TEST_SERVER_PORT`
336+
337+.. setting:: LIVE_TEST_SERVER_PORT
338+
339+LIVE_TEST_SERVER_PORT
340+~~~~~~~~~~~~~~~~~~~~~
341+
342+Default: ``8081``
343+
344+Controls the port at which the live test server gets started when using
345+a :class:`~django.test.LiveServerTestCase`.
346+
347+See also: :setting:`LIVE_TEST_SERVER_HOST`
348+
349 .. setting:: CACHES-LOCATION
350 
351 LOCATION
352diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt
353index 181b4ff..fdf4e81 100644
354--- a/docs/topics/testing.txt
355+++ b/docs/topics/testing.txt
356@@ -580,21 +580,20 @@ Some of the things you can do with the test client are:
357 * Test that a given request is rendered by a given Django template, with
358   a template context that contains certain values.
359 
360-Note that the test client is not intended to be a replacement for Twill_,
361+Note that the test client is not intended to be a replacement for Windmill_,
362 Selenium_, or other "in-browser" frameworks. Django's test client has
363 a different focus. In short:
364 
365 * Use Django's test client to establish that the correct view is being
366   called and that the view is collecting the correct context data.
367 
368-* Use in-browser frameworks such as Twill and Selenium to test *rendered*
369-  HTML and the *behavior* of Web pages, namely JavaScript functionality.
370+* Use in-browser frameworks such as Windmill_ and Selenium_ to test *rendered*
371+  HTML and the *behavior* of Web pages, namely JavaScript functionality. Django
372+  also provides special support for those frameworks; see the section on
373+  :class:`~django.test.LiveServerTestCase` for more details.
374 
375 A comprehensive test suite should use a combination of both test types.
376 
377-.. _Twill: http://twill.idyll.org/
378-.. _Selenium: http://seleniumhq.org/
379-
380 Overview and a quick example
381 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
382 
383@@ -1830,11 +1829,84 @@ set up, execute and tear down the test suite.
384     those options will be added to the list of command-line options that
385     the :djadmin:`test` command can use.
386 
387+Live test server
388+----------------
389+
390+.. versionadded:: 1.4
391+
392+.. currentmodule:: django.test
393+
394+.. class:: LiveServerTestCase()
395+
396+``LiveServerTestCase`` does basically the same as
397+:class:`~django.test.TransactionTestCase` with one extra thing: it launches a
398+live Django server in the background on setup, and shuts it down on teardown.
399+This allows to use other automated test clients than the
400+:ref:`Django dummy client <test-client>` such as, for example, the Selenium_ or
401+Windmill_ clients, to execute a series of functional tests inside a browser and
402+simulate a real user's actions.
403+
404+You may control which host and port the live server will run at with
405+respectively the :setting:`LIVE_TEST_SERVER_HOST` and
406+:setting:`LIVE_TEST_SERVER_PORT` settings. The full server url can then be
407+accessed during the tests with ``self.live_server_url``.
408+
409+To demonstrate how to use ``LiveServerTestCase``, let's write a simple Selenium
410+test. First of all, you need to install the `selenium package`_ into your
411+Python path:
412+
413+.. code-block:: bash
414+
415+   pip install selenium
416+
417+Then, add the following code to one of your app's tests module (for example:
418+``myapp/tests.py``):
419+
420+.. code-block:: python
421+
422+    from django.test import LiveServerTestCase
423+    from selenium.webdriver.firefox.webdriver import WebDriver
424+
425+    class MySeleniumTests(LiveServerTestCase):
426+        fixtures = ['user-data.json']
427+
428+        def setUp(self):
429+            self.selenium = WebDriver()
430+            super(MySeleniumTests, self).setUp()
431+
432+        def tearDown(self):
433+            super(MySeleniumTests, self).tearDown()
434+            self.selenium.quit()
435+
436+        def test_login(self):
437+            self.selenium.get('%s%s' % (self.live_server_url, '/login/'))
438+            username_input = self.selenium.find_element_by_name("username")
439+            username_input.send_keys('myuser')
440+            password_input = self.selenium.find_element_by_name("password")
441+            password_input.send_keys('secret')
442+            self.selenium.find_element_by_xpath('//input[@value="Log in"]').click()
443+
444+Finally, you may run the test as follows:
445+
446+.. code-block:: bash
447+
448+    ./manage.py test myapp.MySeleniumTests.test_login
449+
450+This example will automatically open Firefox then go to the login page, enter
451+the credentials and press the "Log in" button. Selenium offers other drivers in
452+case you do not have Firefox installed or wish to use another browser. The
453+example above is just a tiny fraction of what the Selenium client can do; check
454+out the `full reference`_ for more details.
455+
456+.. _Windmill: http://www.getwindmill.com/
457+.. _Selenium: http://seleniumhq.org/
458+.. _selenium package: http://pypi.python.org/pypi/selenium
459+.. _full reference: http://readthedocs.org/docs/selenium-python/en/latest/api.html
460+.. _Firefox: http://www.mozilla.com/firefox/
461 
462 Attributes
463 ~~~~~~~~~~
464 
465-
466 .. attribute:: DjangoTestSuiteRunner.option_list
467 
468     .. versionadded:: 1.4
469diff --git a/tests/regressiontests/admin_inlines/tests.py b/tests/regressiontests/admin_inlines/tests.py
470index c2e3bbc..d4f8f67 100644
471--- a/tests/regressiontests/admin_inlines/tests.py
472+++ b/tests/regressiontests/admin_inlines/tests.py
473@@ -1,5 +1,6 @@
474 from __future__ import absolute_import
475 
476+from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase
477 from django.contrib.admin.helpers import InlineAdminForm
478 from django.contrib.auth.models import User, Permission
479 from django.contrib.contenttypes.models import ContentType
480@@ -380,3 +381,95 @@ class TestInlinePermissions(TestCase):
481         self.assertContains(response, 'value="4" id="id_inner2_set-TOTAL_FORMS"')
482         self.assertContains(response, '<input type="hidden" name="inner2_set-0-id" value="%i"' % self.inner2_id)
483         self.assertContains(response, 'id="id_inner2_set-0-DELETE"')
484+
485+class SeleniumFirefoxTests(AdminSeleniumWebDriverTestCase):
486+    webdriver_class = 'selenium.webdriver.firefox.webdriver.WebDriver'
487+    fixtures = ['admin-views-users.xml']
488+    urls = "regressiontests.admin_inlines.urls"
489+
490+    def test_add_inlines(self):
491+        """
492+        Ensure that the "Add another XXX" link correctly adds items to the
493+        inline form.
494+        """
495+        self.admin_login(username='super', password='secret')
496+        self.selenium.get('%s%s' % (self.live_server_url,
497+            '/admin/admin_inlines/titlecollection/add/'))
498+
499+        # Check that there's only one inline to start with and that it has the
500+        # correct ID.
501+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
502+            '#title_set-group table tr.dynamic-title_set')), 1)
503+        self.failUnlessEqual(self.selenium.find_element_by_css_selector(
504+            '.dynamic-title_set:nth-of-type(1)').get_attribute('id'),
505+            'title_set-0')
506+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
507+            'form#titlecollection_form tr.dynamic-title_set#title_set-0 input[name=title_set-0-title1]')), 1)
508+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
509+            'form#titlecollection_form tr.dynamic-title_set#title_set-0 input[name=title_set-0-title2]')), 1)
510+
511+        # Add an inline
512+        self.selenium.find_element_by_link_text('Add another Title').click()
513+
514+        # Check that the inline has been added, that it has the right id, and
515+        # that it contains the right fields.
516+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
517+            '#title_set-group table tr.dynamic-title_set')), 2)
518+        self.failUnlessEqual(self.selenium.find_element_by_css_selector(
519+            '.dynamic-title_set:nth-of-type(2)').get_attribute('id'), 'title_set-1')
520+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
521+            'form#titlecollection_form tr.dynamic-title_set#title_set-1 input[name=title_set-1-title1]')), 1)
522+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
523+            'form#titlecollection_form tr.dynamic-title_set#title_set-1 input[name=title_set-1-title2]')), 1)
524+
525+        # Let's add another one to be sure
526+        self.selenium.find_element_by_link_text('Add another Title').click()
527+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
528+            '#title_set-group table tr.dynamic-title_set')), 3)
529+        self.failUnlessEqual(self.selenium.find_element_by_css_selector(
530+            '.dynamic-title_set:nth-of-type(3)').get_attribute('id'), 'title_set-2')
531+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
532+            'form#titlecollection_form tr.dynamic-title_set#title_set-2 input[name=title_set-2-title1]')), 1)
533+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
534+            'form#titlecollection_form tr.dynamic-title_set#title_set-2 input[name=title_set-2-title2]')), 1)
535+
536+    def test_delete_inlines(self):
537+        self.admin_login(username='super', password='secret')
538+        self.selenium.get('%s%s' % (self.live_server_url,
539+            '/admin/admin_inlines/titlecollection/add/'))
540+
541+        # Add a few inlines
542+        self.selenium.find_element_by_link_text('Add another Title').click()
543+        self.selenium.find_element_by_link_text('Add another Title').click()
544+        self.selenium.find_element_by_link_text('Add another Title').click()
545+        self.selenium.find_element_by_link_text('Add another Title').click()
546+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
547+            '#title_set-group table tr.dynamic-title_set')), 5)
548+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
549+            'form#titlecollection_form tr.dynamic-title_set#title_set-0')), 1)
550+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
551+            'form#titlecollection_form tr.dynamic-title_set#title_set-1')), 1)
552+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
553+            'form#titlecollection_form tr.dynamic-title_set#title_set-2')), 1)
554+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
555+            'form#titlecollection_form tr.dynamic-title_set#title_set-3')), 1)
556+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
557+            'form#titlecollection_form tr.dynamic-title_set#title_set-4')), 1)
558+
559+        # Click on a few delete buttons
560+        self.selenium.find_element_by_css_selector(
561+            'form#titlecollection_form tr.dynamic-title_set#title_set-1 td.delete a').click()
562+        self.selenium.find_element_by_css_selector(
563+            'form#titlecollection_form tr.dynamic-title_set#title_set-2 td.delete a').click()
564+        # Verify that they're gone and that the IDs have been re-sequenced
565+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
566+            '#title_set-group table tr.dynamic-title_set')), 3)
567+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
568+            'form#titlecollection_form tr.dynamic-title_set#title_set-0')), 1)
569+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
570+            'form#titlecollection_form tr.dynamic-title_set#title_set-1')), 1)
571+        self.failUnlessEqual(len(self.selenium.find_elements_by_css_selector(
572+            'form#titlecollection_form tr.dynamic-title_set#title_set-2')), 1)
573+
574+class SeleniumInternetExplorerTests(SeleniumFirefoxTests):
575+    webdriver_class = 'selenium.webdriver.ie.webdriver.WebDriver'
576\ No newline at end of file
577diff --git a/tests/regressiontests/admin_widgets/tests.py b/tests/regressiontests/admin_widgets/tests.py
578index 37fa7bc..ecf3165 100644
579--- a/tests/regressiontests/admin_widgets/tests.py
580+++ b/tests/regressiontests/admin_widgets/tests.py
581@@ -7,6 +7,7 @@ from django import forms
582 from django.conf import settings
583 from django.contrib import admin
584 from django.contrib.admin import widgets
585+from django.contrib.admin.tests import AdminSeleniumWebDriverTestCase
586 from django.core.files.storage import default_storage
587 from django.core.files.uploadedfile import SimpleUploadedFile
588 from django.db.models import DateField
589@@ -407,3 +408,55 @@ class RelatedFieldWidgetWrapperTests(DjangoTestCase):
590         # Used to fail with a name error.
591         w = widgets.RelatedFieldWidgetWrapper(w, rel, widget_admin_site)
592         self.assertFalse(w.can_add_related)
593+
594+
595+class SeleniumFirefoxTests(AdminSeleniumWebDriverTestCase):
596+    webdriver_class = 'selenium.webdriver.firefox.webdriver.WebDriver'
597+    fixtures = ['admin-widgets-users.xml']
598+    urls = "regressiontests.admin_widgets.urls"
599+
600+    def test_show_hide_date_time_picker_widgets(self):
601+        """
602+        Ensure that pressing the ESC key closes the date and time picker
603+        widgets.
604+        Refs #17064.
605+        """
606+        from selenium.webdriver.common.keys import Keys
607+
608+        self.admin_login(username='super', password='secret', login_url='/')
609+        # Open a page that has a date and time picker widgets
610+        self.selenium.get('%s%s' % (self.live_server_url,
611+            '/admin_widgets/member/add/'))
612+
613+        # First, with the date picker widget ---------------------------------
614+        # Check that the date picker is hidden
615+        self.assertEqual(
616+            self.get_css_value('#calendarbox0', 'display'), 'none')
617+        # Click the calendar icon
618+        self.selenium.find_element_by_id('calendarlink0').click()
619+        # Check that the date picker is visible
620+        self.assertEqual(
621+            self.get_css_value('#calendarbox0', 'display'), 'block')
622+        # Press the ESC key
623+        self.selenium.find_element_by_tag_name('html').send_keys([Keys.ESCAPE])
624+        # Check that the date picker is hidden again
625+        self.assertEqual(
626+            self.get_css_value('#calendarbox0', 'display'), 'none')
627+
628+        # Then, with the time picker widget ----------------------------------
629+        # Check that the time picker is hidden
630+        self.assertEqual(
631+            self.get_css_value('#clockbox0', 'display'), 'none')
632+        # Click the time icon
633+        self.selenium.find_element_by_id('clocklink0').click()
634+        # Check that the time picker is visible
635+        self.assertEqual(
636+            self.get_css_value('#clockbox0', 'display'), 'block')
637+        # Press the ESC key
638+        self.selenium.find_element_by_tag_name('html').send_keys([Keys.ESCAPE])
639+        # Check that the time picker is hidden again
640+        self.assertEqual(
641+            self.get_css_value('#clockbox0', 'display'), 'none')
642+
643+class SeleniumInternetExplorerTests(SeleniumFirefoxTests):
644+    webdriver_class = 'selenium.webdriver.ie.webdriver.WebDriver'
645\ No newline at end of file
646diff --git a/tests/regressiontests/live_server/__init__.py b/tests/regressiontests/live_server/__init__.py
647new file mode 100644
648index 0000000..e69de29
649diff --git a/tests/regressiontests/live_server/fixtures/testdata.json b/tests/regressiontests/live_server/fixtures/testdata.json
650new file mode 100644
651index 0000000..e987a04
652--- /dev/null
653+++ b/tests/regressiontests/live_server/fixtures/testdata.json
654@@ -0,0 +1,16 @@
655+[
656+  {
657+    "pk": 1,
658+    "model": "live_server.person",
659+    "fields": {
660+      "name": "jane"
661+    }
662+  },
663+  {
664+    "pk": 2,
665+    "model": "live_server.person",
666+    "fields": {
667+      "name": "robert"
668+    }
669+  }
670+]
671\ No newline at end of file
672diff --git a/tests/regressiontests/live_server/media/example_media_file.txt b/tests/regressiontests/live_server/media/example_media_file.txt
673new file mode 100644
674index 0000000..dd2dda9
675--- /dev/null
676+++ b/tests/regressiontests/live_server/media/example_media_file.txt
677@@ -0,0 +1 @@
678+example media file
679diff --git a/tests/regressiontests/live_server/models.py b/tests/regressiontests/live_server/models.py
680new file mode 100644
681index 0000000..6e1414a
682--- /dev/null
683+++ b/tests/regressiontests/live_server/models.py
684@@ -0,0 +1,5 @@
685+from django.db import models
686+
687+
688+class Person(models.Model):
689+    name = models.CharField(max_length=256)
690diff --git a/tests/regressiontests/live_server/static/example_file.txt b/tests/regressiontests/live_server/static/example_file.txt
691new file mode 100644
692index 0000000..5f1cfce
693--- /dev/null
694+++ b/tests/regressiontests/live_server/static/example_file.txt
695@@ -0,0 +1 @@
696+example file
697diff --git a/tests/regressiontests/live_server/tests.py b/tests/regressiontests/live_server/tests.py
698new file mode 100644
699index 0000000..67acf5d
700--- /dev/null
701+++ b/tests/regressiontests/live_server/tests.py
702@@ -0,0 +1,83 @@
703+import os
704+import urllib2
705+
706+from django.conf import settings
707+from django.test import LiveServerTestCase
708+from django.test.utils import override_settings
709+
710+from .models import Person
711+
712+
713+TEST_ROOT = os.path.dirname(__file__)
714+TEST_SETTINGS = {
715+    'MEDIA_URL': '/media/',
716+    'MEDIA_ROOT': os.path.join(TEST_ROOT, 'media'),
717+    'STATIC_URL': '/static/',
718+    'STATIC_ROOT': os.path.join(TEST_ROOT, 'static'),
719+}
720+
721+
722+class LiveServerTestBase(LiveServerTestCase):
723+    urls = 'regressiontests.live_server.urls'
724+    fixtures = ['testdata.json']
725+
726+    def urlopen(self, url):
727+        base = 'http://%s:%s' % (settings.LIVE_TEST_SERVER_HOST,
728+                                 settings.LIVE_TEST_SERVER_PORT)
729+        return urllib2.urlopen(base + url)
730+
731+
732+class TestViews(LiveServerTestBase):
733+    def test_404(self):
734+        """
735+        Ensure that the LiveServerTestCase serves 404s.
736+        """
737+        try:
738+            self.urlopen('/')
739+        except urllib2.HTTPError, err:
740+            self.assertEquals(err.code, 404, 'Expected 404 response')
741+        else:
742+            self.fail('Expected 404 response')
743+
744+    def test_view(self):
745+        """
746+        Ensure that the LiveServerTestCase serves views.
747+        """
748+        f = self.urlopen('/example_view/')
749+        self.assertEquals(f.read(), 'example view')
750+
751+    def test_static_file(self):
752+        """
753+        Ensure that the LiveServerTestCase serves static files.
754+        """
755+        f = self.urlopen('/static/example_file.txt')
756+        self.assertEquals(f.read(), 'example file\n')
757+
758+    def test_media_files(self):
759+        """
760+        Ensure that the LiveServerTestCase serves media files.
761+        """
762+        f = self.urlopen('/media/example_media_file.txt')
763+        self.assertEquals(f.read(), 'example media file\n')
764+
765+TestViews = override_settings(**TEST_SETTINGS)(TestViews)
766+
767+
768+class TestDatabase(LiveServerTestBase):
769+
770+    def test_fixtures_loaded(self):
771+        """
772+        Ensure that fixtures are properly loaded and visible to the
773+        live server thread.
774+        """
775+        import pdb; pdb.set_trace()
776+        f = self.urlopen('/model_view/')
777+        self.assertEquals(f.read().splitlines(), ['jane', 'robert'])
778+
779+    def test_database_writes(self):
780+        """
781+        Ensure that data written to the database by a view can be read.
782+        """
783+        self.urlopen('/create_model_instance/')
784+        names = [person.name for person in Person.objects.all()]
785+        self.assertEquals(names, ['jane', 'robert', 'emily'])
786diff --git a/tests/regressiontests/live_server/urls.py b/tests/regressiontests/live_server/urls.py
787new file mode 100644
788index 0000000..83f757f
789--- /dev/null
790+++ b/tests/regressiontests/live_server/urls.py
791@@ -0,0 +1,12 @@
792+from __future__ import absolute_import
793+
794+from django.conf.urls import patterns, url
795+
796+from . import views
797+
798+
799+urlpatterns = patterns('',
800+    url(r'^example_view/$', views.example_view),
801+    url(r'^model_view/$', views.model_view),
802+    url(r'^create_model_instance/$', views.create_model_instance),
803+)
804diff --git a/tests/regressiontests/live_server/views.py b/tests/regressiontests/live_server/views.py
805new file mode 100644
806index 0000000..bfc7513
807--- /dev/null
808+++ b/tests/regressiontests/live_server/views.py
809@@ -0,0 +1,18 @@
810+from django.http import HttpResponse
811+from .models import Person
812+
813+
814+def example_view(request):
815+    return HttpResponse('example view')
816+
817+
818+def model_view(request):
819+    people = Person.objects.all()
820+    import pdb; pdb.set_trace()
821+    return HttpResponse('\n'.join([person.name for person in people]))
822+
823+
824+def create_model_instance(request):
825+    person = Person(name='emily')
826+    person.save()
827+    return HttpResponse('')