Code

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

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