Code

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

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