Code

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

File 2879.selenium-support.4.diff, 28.6 KB (added by julien, 2 years ago)
Line 
1diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py
2index 6b09be2..ed72df9 100644
3--- a/django/conf/global_settings.py
4+++ b/django/conf/global_settings.py
5@@ -560,6 +560,15 @@ 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 = 8080
12+
13+# For Selenium
14+SELENIUM_SERVER_HOST = 'localhost'
15+SELENIUM_SERVER_PORT = 4444
16+SELENIUM_BROWSER = '*firefox'
17+
18 ############
19 # FIXTURES #
20 ############
21diff --git a/django/test/__init__.py b/django/test/__init__.py
22index a3a03e3..b246821 100644
23--- a/django/test/__init__.py
24+++ b/django/test/__init__.py
25@@ -4,5 +4,6 @@ Django Unit Test and Doctest framework.
26 
27 from django.test.client import Client, RequestFactory
28 from django.test.testcases import (TestCase, TransactionTestCase,
29-        SimpleTestCase, skipIfDBFeature, skipUnlessDBFeature)
30+    SimpleTestCase, LiveServerTestCase, SeleniumTestCase, skipIfDBFeature,
31+    skipUnlessDBFeature)
32 from django.test.utils import Approximate
33diff --git a/django/test/testcases.py b/django/test/testcases.py
34index ee22ac2..aebb30a 100644
35--- a/django/test/testcases.py
36+++ b/django/test/testcases.py
37@@ -1,16 +1,24 @@
38 from __future__ import with_statement
39 
40+import httplib
41 import re
42 import sys
43 from functools import wraps
44 from urlparse import urlsplit, urlunsplit
45 from xml.dom.minidom import parseString, Node
46+import select
47+import socket
48+import threading
49 
50 from django.conf import settings
51+from django.contrib.staticfiles.handlers import StaticFilesHandler
52 from django.core import mail
53 from django.core.exceptions import ValidationError
54+from django.core.handlers.wsgi import WSGIHandler
55 from django.core.management import call_command
56 from django.core.signals import request_started
57+from django.core.servers.basehttp import (WSGIRequestHandler, WSGIServer,
58+    WSGIServerException)
59 from django.core.urlresolvers import clear_url_caches
60 from django.core.validators import EMPTY_VALUES
61 from django.db import (transaction, connection, connections, DEFAULT_DB_ALIAS,
62@@ -23,6 +31,7 @@ from django.test.utils import (get_warnings_state, restore_warnings_state,
63     override_settings)
64 from django.utils import simplejson, unittest as ut2
65 from django.utils.encoding import smart_str
66+from django.utils.unittest import skipUnless
67 
68 __all__ = ('DocTestRunner', 'OutputChecker', 'TestCase', 'TransactionTestCase',
69            'SimpleTestCase', 'skipIfDBFeature', 'skipUnlessDBFeature')
70@@ -732,3 +741,200 @@ def skipUnlessDBFeature(feature):
71     """
72     return _deferredSkip(lambda: not getattr(connection.features, feature),
73                          "Database doesn't support feature %s" % feature)
74+
75+class QuietWSGIRequestHandler(WSGIRequestHandler):
76+    """
77+    Just a regular WSGIRequestHandler except it doesn't log to the standard
78+    output any of the requests received, so as to not clutter the output for
79+    the tests' results.
80+    """
81+    def log_message(*args):
82+        pass
83+
84+class StoppableWSGIServer(WSGIServer):
85+    """
86+    The code in this class is borrowed from the `SocketServer.BaseServer` class
87+    in Python 2.6. The important functionality here is that the server is non-
88+    blocking and that it can be shut down at any moment. This is made possible
89+    by the server regularly polling the socket and checking if it has been
90+    asked to stop.
91+    Note for the future: Once Django stops supporting Python 2.5, this class
92+    can be removed as `WSGIServer` will have this ability to shutdown on
93+    demand.
94+    """
95+
96+    def __init__(self, *args, **kwargs):
97+        super(StoppableWSGIServer, self).__init__(*args, **kwargs)
98+        self.__is_shut_down = threading.Event()
99+        self.__serving = False
100+
101+    def serve_forever(self, poll_interval=0.5):
102+        """Handle one request at a time until shutdown.
103+
104+        Polls for shutdown every poll_interval seconds.
105+        """
106+        self.__serving = True
107+        self.__is_shut_down.clear()
108+        while self.__serving:
109+            r, w, e = select.select([self], [], [], poll_interval)
110+            if r:
111+                self._handle_request_noblock()
112+        self.__is_shut_down.set()
113+
114+    def shutdown(self):
115+        """Stops the serve_forever loop.
116+
117+        Blocks until the loop has finished. This must be called while
118+        serve_forever() is running in another thread, or it will
119+        deadlock.
120+        """
121+        self.__serving = False
122+        self.__is_shut_down.wait()
123+
124+    def handle_request(self):
125+        """Handle one request, possibly blocking.
126+        """
127+        fd_sets = select.select([self], [], [], None)
128+        if not fd_sets[0]:
129+            return
130+        self._handle_request_noblock()
131+
132+    def _handle_request_noblock(self):
133+        """Handle one request, without blocking.
134+
135+        I assume that select.select has returned that the socket is
136+        readable before this function was called, so there should be
137+        no risk of blocking in get_request().
138+        """
139+        try:
140+            request, client_address = self.get_request()
141+        except socket.error:
142+            return
143+        if self.verify_request(request, client_address):
144+            try:
145+                self.process_request(request, client_address)
146+            except Exception:
147+                self.handle_error(request, client_address)
148+                self.close_request(request)
149+
150+class LiveServerThread(threading.Thread):
151+    """
152+    Thread for running a live http server while the tests are running.
153+    """
154+
155+    def __init__(self, address, port, fixtures):
156+        self.address = address
157+        self.port = port
158+        self.fixtures = fixtures
159+        self.is_ready = threading.Event()
160+        self.error = None
161+        super(LiveServerThread, self).__init__()
162+
163+    def run(self):
164+        """
165+        Sets up live server and database and loops over handling http requests.
166+        """
167+        try:
168+            # Instantiate and start the server
169+            self.httpd = StoppableWSGIServer(
170+                (self.address, self.port), QuietWSGIRequestHandler)
171+            handler = StaticFilesHandler(WSGIHandler())
172+            self.httpd.set_app(handler)
173+
174+            # If the database is in memory we must reload the data in this new
175+            # thread.
176+            if (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3' or
177+                settings.DATABASES['default']['TEST_NAME']):
178+                connection.creation.create_test_db(0)
179+                # Import the fixtures into the test database
180+                if hasattr(self, 'fixtures'):
181+                    call_command('loaddata', *self.fixtures,
182+                        **{'verbosity': 0})
183+            self.is_ready.set()
184+            self.httpd.serve_forever()
185+        except WSGIServerException, e:
186+            self.error = e
187+            self.is_ready.set()
188+
189+    def join(self, timeout=None):
190+        self.httpd.shutdown()
191+        self.httpd.server_close()
192+        super(LiveServerThread, self).join(timeout)
193+
194+class LiveServerTestCase(TestCase):
195+    """
196+    Does basically the same as TestCase but also launches a live http server in
197+    a separate thread so that the tests may use another testing framework, such
198+    as Selenium for example, instead of the built-in dummy client.
199+    """
200+
201+    fixtures = []
202+
203+    def setUp(self):
204+        # Launch the Django live server's thread
205+        self.server_thread = LiveServerThread(
206+            settings.LIVE_TEST_SERVER_HOST,
207+            int(settings.LIVE_TEST_SERVER_PORT),
208+            fixtures=self.fixtures)
209+        self.server_thread.start()
210+        super(LiveServerTestCase, self).setUp()
211+
212+    def tearDown(self):
213+        # Terminate the Django server's thread
214+        self.server_thread.join()
215+        super(LiveServerTestCase, self).tearDown()
216+
217+try:
218+    # Check if the 'selenium' package is installed
219+    from selenium import selenium
220+    selenium_installed = True
221+except ImportError:
222+    selenium_installed = False
223+
224+# Check if the Selenium server is running
225+try:
226+    conn = httplib.HTTPConnection(settings.SELENIUM_SERVER_HOST,
227+        settings.SELENIUM_SERVER_PORT)
228+    try:
229+        conn.request("GET", "/selenium-server/driver/", '', {})
230+    finally:
231+        conn.close()
232+    selenium_server_running = True
233+except socket.error:
234+    selenium_server_running = False
235+
236+class SeleniumTestCase(LiveServerTestCase):
237+    """
238+    Does basically the same as TestServerTestCase but also connects to the
239+    Selenium server. The selenium client is then available with
240+    'self.selenium'. The requirements are to have the 'selenium' installed in
241+    the python path and to have the Selenium server running. If those
242+    requirements are not filled then the tests will be skipped.
243+    """
244+
245+    def setUp(self):
246+        super(SeleniumTestCase, self).setUp()
247+        # Launch the Selenium server
248+        selenium_browser_url = 'http://%s:%s' % (
249+            settings.LIVE_TEST_SERVER_HOST, settings.LIVE_TEST_SERVER_PORT)
250+        self.selenium = selenium(
251+            settings.SELENIUM_SERVER_HOST,
252+            int(settings.SELENIUM_SERVER_PORT),
253+            settings.SELENIUM_BROWSER,
254+            selenium_browser_url)
255+        self.selenium.start()
256+        # Wait for the Django server to be ready
257+        self.server_thread.is_ready.wait()
258+        if self.server_thread.error:
259+            raise self.server_thread.error
260+
261+    def tearDown(self):
262+        super(SeleniumTestCase, self).tearDown()
263+        self.selenium.stop()
264+
265+SeleniumTestCase = skipUnless(selenium_installed,
266+    'The \'selenium\' package isn\'t installed')(SeleniumTestCase)
267+SeleniumTestCase = skipUnless(selenium_server_running,
268+    'Can\'t connect to the Selenium server using address %s and port %s' % (
269+    settings.SELENIUM_SERVER_HOST, settings.SELENIUM_SERVER_PORT)
270+)(SeleniumTestCase)
271\ No newline at end of file
272diff --git a/docs/internals/contributing/writing-code/unit-tests.txt b/docs/internals/contributing/writing-code/unit-tests.txt
273index 5ec09fe..cec408c 100644
274--- a/docs/internals/contributing/writing-code/unit-tests.txt
275+++ b/docs/internals/contributing/writing-code/unit-tests.txt
276@@ -122,6 +122,31 @@ Going beyond that, you can specify an individual test method like this:
277 
278     ./runtests.py --settings=path.to.settings i18n.TranslationTests.test_lazy_objects
279 
280+Running the Selenium tests
281+~~~~~~~~~~~~~~~~~~~~~~~~~~
282+
283+Some admin tests require Selenium to work via a real Web browser. To allow
284+those tests to run and not be skipped, you must install the selenium_ package
285+into your Python path and download the `Selenium server (>2.12.0)`_. The
286+Selenium server must then be started with the following command:
287+
288+.. code-block:: bash
289+
290+    java -jar selenium-server-standalone-2.12.0.jar
291+
292+If you're using linux, you may also run the tests in headless mode (i.e. with a
293+virtual display) using with the following command instead:
294+
295+.. code-block:: bash
296+
297+    Xvfb :99 -ac & && DISPLAY=:99 java -jar selenium-server-standalone-2.12.0.jar
298+
299+Then, run the tests normally, for example:
300+
301+.. code-block:: bash
302+
303+    ./runtests.py --settings=test_sqlite admin_inlines
304+
305 Running all the tests
306 ~~~~~~~~~~~~~~~~~~~~~
307 
308@@ -135,6 +160,7 @@ dependencies:
309 *  setuptools_
310 *  memcached_, plus a :ref:`supported Python binding <memcached>`
311 *  gettext_ (:ref:`gettext_on_windows`)
312+*  selenium_ plus the `Selenium server (>2.12.0)`_
313 
314 If you want to test the memcached cache backend, you'll also need to define
315 a :setting:`CACHES` setting that points at your memcached instance.
316@@ -149,6 +175,8 @@ associated tests will be skipped.
317 .. _setuptools: http://pypi.python.org/pypi/setuptools/
318 .. _memcached: http://www.danga.com/memcached/
319 .. _gettext: http://www.gnu.org/software/gettext/manual/gettext.html
320+.. _selenium: http://pypi.python.org/pypi/selenium
321+.. _Selenium server (>2.12.0): http://seleniumhq.org/download/
322 
323 .. _contrib-apps:
324 
325diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt
326index 20366e3..16caf48 100644
327--- a/docs/ref/settings.txt
328+++ b/docs/ref/settings.txt
329@@ -195,6 +195,30 @@ all cache keys used by the Django server.
330 
331 See the :ref:`cache documentation <cache_key_prefixing>` for more information.
332 
333+.. setting:: LIVE_TEST_SERVER_HOST
334+
335+LIVE_TEST_SERVER_HOST
336+~~~~~~~~~~~~~~~~~~~~~
337+
338+Default: ``'localhost'``
339+
340+Controls the host address at which the live test server gets started when using
341+a :class:`~django.test.LiveServerTestCase`.
342+
343+See also: :setting:`LIVE_TEST_SERVER_PORT`
344+
345+.. setting:: LIVE_TEST_SERVER_PORT
346+
347+LIVE_TEST_SERVER_PORT
348+~~~~~~~~~~~~~~~~~~~~~
349+
350+Default: ``8080``
351+
352+Controls the port at which the live test server gets started when using
353+a :class:`~django.test.LiveServerTestCase`.
354+
355+See also: :setting:`LIVE_TEST_SERVER_HOST`
356+
357 .. setting:: CACHES-LOCATION
358 
359 LOCATION
360@@ -496,6 +520,53 @@ Default: ``''`` (Empty string)
361 The port to use when connecting to the database. An empty string means the
362 default port. Not used with SQLite.
363 
364+.. setting:: SELENIUM_SERVER_HOST
365+
366+SELENIUM_SERVER_HOST
367+~~~~~~~~~~~~~~~~~~~~
368+
369+Default: ``localhost``
370+
371+Host address where the Selenium server can be accessed.
372+
373+.. setting:: SELENIUM_SERVER_PORT
374+
375+SELENIUM_SERVER_PORT
376+~~~~~~~~~~~~~~~~~~~~
377+
378+Default: ``4444``
379+
380+Port where the Selenium server can be accessed.
381+
382+.. setting:: SELENIUM_BROWSER
383+
384+SELENIUM_BROWSER
385+~~~~~~~~~~~~~~~~
386+
387+Default: ``'*firefox'``
388+
389+Browser to be used when running Selenium tests. Note that the prefixing star
390+('``*``') is required. Possible values include:
391+
392+*  ``'*firefox'``
393+*  ``'*googlechrome'``
394+*  ``'*safari'``
395+*  ``'*mock'``
396+*  ``'*firefoxproxy'``
397+*  ``'*pifirefox'``
398+*  ``'*chrome'``
399+*  ``'*iexploreproxy'``
400+*  ``'*iexplore'``
401+*  ``'*safariproxy'``
402+*  ``'*konqueror'``
403+*  ``'*firefox2'``
404+*  ``'*firefox3'``
405+*  ``'*firefoxchrome'``
406+*  ``'*piiexplore'``
407+*  ``'*opera'``
408+*  ``'*iehta'``
409+*  ``'*custom'``
410+
411 .. setting:: USER
412 
413 USER
414diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt
415index dc5bf7e..9b9ed1c 100644
416--- a/docs/topics/testing.txt
417+++ b/docs/topics/testing.txt
418@@ -580,21 +580,21 @@ Some of the things you can do with the test client are:
419 * Test that a given request is rendered by a given Django template, with
420   a template context that contains certain values.
421 
422-Note that the test client is not intended to be a replacement for Twill_,
423+Note that the test client is not intended to be a replacement for Windmill_,
424 Selenium_, or other "in-browser" frameworks. Django's test client has
425 a different focus. In short:
426 
427 * Use Django's test client to establish that the correct view is being
428   called and that the view is collecting the correct context data.
429 
430-* Use in-browser frameworks such as Twill and Selenium to test *rendered*
431-  HTML and the *behavior* of Web pages, namely JavaScript functionality.
432+* Use in-browser frameworks such as Windmill_ and Selenium_ to test *rendered*
433+  HTML and the *behavior* of Web pages, namely JavaScript functionality. Django
434+  also provides special support for those frameworks; see the sections on
435+  :class:`~django.test.testcases.LiveServerTestCase` and
436+  :class:`~django.test.testcases.SeleniumTestCase`.
437 
438 A comprehensive test suite should use a combination of both test types.
439 
440-.. _Twill: http://twill.idyll.org/
441-.. _Selenium: http://seleniumhq.org/
442-
443 Overview and a quick example
444 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
445 
446@@ -1828,11 +1828,114 @@ set up, execute and tear down the test suite.
447     those options will be added to the list of command-line options that
448     the :djadmin:`test` command can use.
449 
450+Live test server
451+----------------
452+
453+.. currentmodule:: django.test
454+
455+.. versionadded::1.4
456+
457+.. class:: LiveServerTestCase()
458+
459+``LiveServerTestCase`` does basically the same as
460+:class:`~django.test.TestCase` with one extra thing: it launches a live Django
461+server in the background on setup, and shuts it down on teardown. This allows
462+to use other automated test clients than the
463+:ref:`Django dummy client <test-client>` such as, for example, the Selenium_ or
464+Windmill_ clients.
465+
466+You may control which host and port the live server will run at with
467+respectively the :setting:`LIVE_TEST_SERVER_HOST` and
468+:setting:`LIVE_TEST_SERVER_PORT` settings.
469+
470+Fixtures defined with the :attr:`~TestCase.fixtures` class attribute will get
471+loaded at the beginning of each test if you require some initial data to be
472+present.
473+
474+See the section on :class:`SeleniumTestCase` for a concrete example of how
475+``LiveServerTestCase`` can be used.
476+
477+Selenium tests
478+--------------
479+
480+.. versionadded::1.4
481+
482+.. class:: SeleniumTestCase()
483+
484+Django provides out-of-the box support for Selenium_ tests with the
485+``SeleniumTestCase`` class. Django itself uses it in its own test suite for
486+some ``contrib.admin`` tests.
487+
488+``SeleniumTestCase`` inherits from :class:`LiveServerTestCase`, which means
489+that a live server is available for the duration of each test. That live server
490+can then be accessed by the Selenium client to execute a series of functional
491+tests inside a browser, simulating a real user's actions.
492+
493+To get started with Selenium tests, your environment needs to satisfy a number
494+of requirements:
495+
496+*  You must install the `selenium package`_ into your Python path:
497+
498+   .. code-block:: bash
499+
500+       pip install selenium
501+
502+*  You must download the `Selenium server (>2.12.0)`_, and then start it with
503+   the following command:
504+
505+   .. code-block:: bash
506+
507+       java -jar selenium-server-standalone-2.12.0.jar
508+
509+   If you'd like to run the selenium server at a different port than the
510+   standard one (i.e. 4444) you may do so as follows:
511+
512+   .. code-block:: bash
513+
514+       java -jar selenium-server-standalone-2.12.0.jar -port 1234
515+
516+*  If the selenium server isn't running at the standard host address or port,
517+   you need to provide the exact details using respectively the
518+   :setting:`SELENIUM_SERVER_HOST` and :setting:`SELENIUM_SERVER_PORT`
519+   settings.
520+
521+If those requirements are not satisfied, then the tests will be skipped.
522+
523+By the default, the tests are run in Firefox_. If you do not have Firefox
524+installed or simply wish to run the tests in another browser, you may do so by
525+changing the :setting:`SELENIUM_BROWSER` setting.
526+
527+Once your environment is set up, you may start writing your Selenium tests.
528+Here's an example of how to control the Selenium client (accessible via
529+``self.selenium``):
530+
531+.. code-block:: python
532+
533+    from django.test import SeleniumTestCase
534+
535+    class MySeleniumTests(SeleniumTestCase):
536+
537+        fixtures = ['user-data.json']
538+
539+        def test_login(self):
540+            self.selenium.open('/login/')
541+            self.selenium.type('username', username)
542+            self.selenium.type('password', password)
543+            self.selenium.click("//input[@value='Log in']")
544+
545+This is just a tiny fraction of what the Selenium client can do. Check out the
546+`full reference`_ for more details.
547+
548+.. _Windmill: http://www.getwindmill.com/
549+.. _Selenium: http://seleniumhq.org/
550+.. _selenium package: http://pypi.python.org/pypi/selenium
551+.. _Selenium server (>2.12.0): http://seleniumhq.org/download/
552+.. _full reference: http://selenium.googlecode.com/svn/trunk/docs/api/py/selenium/selenium.selenium.html
553+.. _Firefox: http://www.mozilla.com/firefox/
554 
555 Attributes
556 ~~~~~~~~~~
557 
558-
559 .. attribute:: DjangoTestSuiteRunner.option_list
560 
561     .. versionadded:: 1.4
562diff --git a/tests/regressiontests/admin_inlines/tests.py b/tests/regressiontests/admin_inlines/tests.py
563index c2e3bbc..942ece6 100644
564--- a/tests/regressiontests/admin_inlines/tests.py
565+++ b/tests/regressiontests/admin_inlines/tests.py
566@@ -7,6 +7,7 @@ from django.test import TestCase
567 
568 # local test models
569 from .admin import InnerInline
570+from django.test.testcases import SeleniumTestCase
571 from .models import (Holder, Inner, Holder2, Inner2, Holder3, Inner3, Person,
572     OutfitItem, Fashionista, Teacher, Parent, Child, Author, Book)
573 
574@@ -380,3 +381,99 @@ class TestInlinePermissions(TestCase):
575         self.assertContains(response, 'value="4" id="id_inner2_set-TOTAL_FORMS"')
576         self.assertContains(response, '<input type="hidden" name="inner2_set-0-id" value="%i"' % self.inner2_id)
577         self.assertContains(response, 'id="id_inner2_set-0-DELETE"')
578+
579+
580+class SeleniumTests(SeleniumTestCase):
581+    fixtures = ['admin-views-users.xml']
582+    urls = "regressiontests.admin_inlines.urls"
583+
584+    def admin_login(self, username, password):
585+        """
586+        Helper function to log into the admin.
587+        """
588+        self.selenium.open('/admin/')
589+        self.selenium.type('username', username)
590+        self.selenium.type('password', password)
591+        self.selenium.click("//input[@value='Log in']")
592+        self.selenium.wait_for_page_to_load(3000)
593+
594+    def test_add_inlines(self):
595+        """
596+        Ensure that the "Add another XXX" link correctly adds items to the
597+        inline form.
598+        """
599+        self.admin_login(username='super', password='secret')
600+        self.selenium.open('/admin/admin_inlines/titlecollection/add/')
601+
602+        # Check that there's only one inline to start with and that it has the
603+        # correct ID.
604+        self.failUnlessEqual(self.selenium.get_css_count(
605+            'css=#title_set-group table tr.dynamic-title_set'), 1)
606+        self.failUnless(self.selenium.get_attribute(
607+            'css=.dynamic-title_set:nth-of-type(1)@id'), 'title_set-0')
608+        self.failUnless(self.selenium.is_element_present(
609+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-0 input[name=title_set-0-title1]'))
610+        self.failUnless(self.selenium.is_element_present(
611+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-0 input[name=title_set-0-title2]'))
612+
613+        # Add an inline
614+        self.selenium.click("link=Add another Title")
615+
616+        # Check that the inline has been added, that it has the right id, and
617+        # that it contains the right fields.
618+        self.failUnlessEqual(self.selenium.get_css_count(
619+            'css=#title_set-group table tr.dynamic-title_set'), 2)
620+        self.failUnless(self.selenium.get_attribute(
621+            'css=.dynamic-title_set:nth-of-type(2)@id'), 'title_set-1')
622+        self.failUnless(self.selenium.is_element_present(
623+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-1 input[name=title_set-1-title1]'))
624+        self.failUnless(self.selenium.is_element_present(
625+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-1 input[name=title_set-1-title2]'))
626+
627+        # Let's add another one to be sure
628+        self.selenium.click("link=Add another Title")
629+        self.failUnlessEqual(self.selenium.get_css_count(
630+            'css=#title_set-group table tr.dynamic-title_set'), 3)
631+        self.failUnless(self.selenium.get_attribute(
632+            'css=.dynamic-title_set:nth-of-type(3)@id'), 'title_set-2')
633+        self.failUnless(self.selenium.is_element_present(
634+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-2 input[name=title_set-2-title1]'))
635+        self.failUnless(self.selenium.is_element_present(
636+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-2 input[name=title_set-2-title2]'))
637+
638+    def test_delete_inlines(self):
639+        self.admin_login(username='super', password='secret')
640+        self.selenium.open('/admin/admin_inlines/titlecollection/add/')
641+
642+        # Add a few inlines
643+        self.selenium.click("link=Add another Title")
644+        self.selenium.click("link=Add another Title")
645+        self.selenium.click("link=Add another Title")
646+        self.selenium.click("link=Add another Title")
647+        self.failUnlessEqual(self.selenium.get_css_count(
648+            'css=#title_set-group table tr.dynamic-title_set'), 5)
649+        self.failUnless(self.selenium.is_element_present(
650+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-0'))
651+        self.failUnless(self.selenium.is_element_present(
652+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-1'))
653+        self.failUnless(self.selenium.is_element_present(
654+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-2'))
655+        self.failUnless(self.selenium.is_element_present(
656+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-3'))
657+        self.failUnless(self.selenium.is_element_present(
658+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-4'))
659+
660+        # Click on a few delete buttons
661+        self.selenium.click(
662+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-1 td.delete a')
663+        self.selenium.click(
664+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-2 td.delete a')
665+        # Verify that they're gone and that the IDs have been re-sequenced
666+        self.failUnlessEqual(self.selenium.get_css_count(
667+            'css=#title_set-group table tr.dynamic-title_set'), 3)
668+        self.failUnless(self.selenium.is_element_present(
669+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-0'))
670+        self.failUnless(self.selenium.is_element_present(
671+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-1'))
672+        self.failUnless(self.selenium.is_element_present(
673+            'css=form#titlecollection_form tr.dynamic-title_set#title_set-2'))
674\ No newline at end of file
675diff --git a/tests/regressiontests/admin_widgets/tests.py b/tests/regressiontests/admin_widgets/tests.py
676index 08a1a59..c0704d5 100644
677--- a/tests/regressiontests/admin_widgets/tests.py
678+++ b/tests/regressiontests/admin_widgets/tests.py
679@@ -11,6 +11,7 @@ from django.core.files.storage import default_storage
680 from django.core.files.uploadedfile import SimpleUploadedFile
681 from django.db.models import DateField
682 from django.test import TestCase as DjangoTestCase
683+from django.test.testcases import SeleniumTestCase
684 from django.utils import translation
685 from django.utils.html import conditional_escape
686 from django.utils.unittest import TestCase
687@@ -372,3 +373,67 @@ class RelatedFieldWidgetWrapperTests(DjangoTestCase):
688         # Used to fail with a name error.
689         w = widgets.RelatedFieldWidgetWrapper(w, rel, widget_admin_site)
690         self.assertFalse(w.can_add_related)
691+
692+
693+class SeleniumTests(SeleniumTestCase):
694+    fixtures = ['admin-widgets-users.xml']
695+    urls = "regressiontests.admin_widgets.urls"
696+
697+    def admin_login(self, username, password):
698+        """
699+        Helper function to log into the admin.
700+        """
701+        self.selenium.open('/')
702+        self.selenium.type('username', username)
703+        self.selenium.type('password', password)
704+        self.selenium.click("//input[@value='Log in']")
705+        self.selenium.wait_for_page_to_load(3000)
706+
707+    def get_css_value(self, selector, attribute):
708+        """
709+        Helper function that returns the value for the CSS attribute of an
710+        DOM element specified by the given selector.
711+        """
712+        return self.selenium.get_eval(
713+            'selenium.browserbot.getCurrentWindow().django'
714+            '.jQuery("%s").css("%s")' % (selector, attribute))
715+
716+    def test_show_hide_date_time_picker_widgets(self):
717+        """
718+        Ensure that pressing the ESC key closes the date and time picker
719+        widgets.
720+        Refs #17064.
721+        """
722+        self.admin_login(username='super', password='secret')
723+        # Open a page that has a date and time picker widgets
724+        self.selenium.open('/admin_widgets/member/add/')
725+
726+        # First, with the date picker widget ---------------------------------
727+        # Check that the date picker is hidden
728+        self.assertEqual(
729+            self.get_css_value('#calendarbox0', 'display'), 'none')
730+        # Click the calendar icon
731+        self.selenium.click('id=calendarlink0')
732+        # Check that the date picker is visible
733+        self.assertEqual(
734+            self.get_css_value('#calendarbox0', 'display'), 'block')
735+        # Press the ESC key
736+        self.selenium.key_up('css=html', '27')
737+        # Check that the date picker is hidden again
738+        self.assertEqual(
739+            self.get_css_value('#calendarbox0', 'display'), 'none')
740+
741+        # Then, with the time picker widget ----------------------------------
742+        # Check that the time picker is hidden
743+        self.assertEqual(
744+            self.get_css_value('#clockbox0', 'display'), 'none')
745+        # Click the time icon
746+        self.selenium.click('id=clocklink0')
747+        # Check that the time picker is visible
748+        self.assertEqual(
749+            self.get_css_value('#clockbox0', 'display'), 'block')
750+        # Press the ESC key
751+        self.selenium.key_up('css=html', '27')
752+        # Check that the time picker is hidden again
753+        self.assertEqual(
754+            self.get_css_value('#clockbox0', 'display'), 'none')
755\ No newline at end of file