Code

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

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