← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~allenap/maas/try-out-saucelabs into lp:maas

 

Gavin Panella has proposed merging lp:~allenap/maas/try-out-saucelabs into lp:maas.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #996260 in MAAS: "JS tests are not run on different browser types"
  https://bugs.launchpad.net/maas/+bug/996260

For more details, see:
https://code.launchpad.net/~allenap/maas/try-out-saucelabs/+merge/106217

* Introduces some fixtures for integration with the SauceLabs Connect
  and OnDemand services, and also for patching this into SST. SST does
  have some support for this, but I went the route of bypassing its
  set-up and tear-down routines.

* Gets SST tests to report page by page, instead of all at once. It
  does this by overriding TestCase.__call__ to multiply up the test
  for each browser, in a similar way to testscenarios method of
  operation.

* Gets the SST tests running remotely, via the OnDemand/Connect
  service. The browsers to use can be selected by setting the
  MAAS_REMOTE_TEST_BROWSERS to one or more members of {ie7, ie8, ie9,
  chrome}. Not all the SST tests pass.

* When bringing up the SauceConnectFixture, Sauce-Connect.jar will be
  downloaded if it's not already. It's quite a big file so it's best
  not to put it in the tree.

* Works around an annoying wart in nose, so that the actually running
  test is now reported. See `active_test`.

-- 
https://code.launchpad.net/~allenap/maas/try-out-saucelabs/+merge/106217
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~allenap/maas/try-out-saucelabs into lp:maas.
=== modified file 'src/maasserver/tests/test_js.py'
--- src/maasserver/tests/test_js.py	2012-05-15 13:28:31 +0000
+++ src/maasserver/tests/test_js.py	2012-05-17 16:05:35 +0000
@@ -10,21 +10,34 @@
     )
 
 __metaclass__ = type
-__all__ = [
-    'TestYUIUnitTests',
-    ]
+__all__ = []
 
-import BaseHTTPServer
+from abc import (
+    ABCMeta,
+    abstractmethod,
+    )
+from glob import glob
 import json
 import logging
 import os
-from os.path import dirname
-import SimpleHTTPServer
-import SocketServer
-import string
+from os.path import (
+    abspath,
+    dirname,
+    join,
+    )
+from urlparse import urljoin
 
 from fixtures import Fixture
+from maastesting.httpd import HTTPServerFixture
+from maastesting.saucelabs import (
+    SauceConnectFixture,
+    SSTOnDemandFixture,
+    )
+from maastesting.testcase import TestCase
+from maastesting.utils import extract_word_list
+from nose.tools import nottest
 from pyvirtualdisplay import Display
+from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
 from sst.actions import (
     assert_text,
     get_element,
@@ -33,13 +46,16 @@
     stop,
     wait_for,
     )
-from testscenarios import TestWithScenarios
-from testtools import TestCase
+from testtools import clone_test_with_new_id
 
 # Base path where the HTML files will be searched.
 BASE_PATH = 'src/maasserver/static/js/tests/'
 
 
+# Nose is over-zealous.
+nottest(clone_test_with_new_id)
+
+
 class LoggerSilencerFixture(Fixture):
     """Fixture to change the log level of loggers.
 
@@ -79,32 +95,17 @@
         self.addCleanup(self.display.stop)
 
 
-class ThreadingHTTPServer(SocketServer.ThreadingMixIn,
-                          BaseHTTPServer.HTTPServer):
-    """A simple HTTP Server that whill run in it's own thread."""
-
-
-class SilentHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
-    # SimpleHTTPRequestHandler logs to stdout: silence it.
-    log_request = lambda *args, **kwargs: None
-    log_error = lambda *args, **kwargs: None
-
-
 class SSTFixture(Fixture):
     """Setup a javascript-enabled testing browser instance with SST."""
 
     logger_names = ['selenium.webdriver.remote.remote_connection']
 
-    def __init__(self, driver):
-        self.driver = driver
+    def __init__(self, browser_name):
+        self.browser_name = browser_name
 
     def setUp(self):
         super(SSTFixture, self).setUp()
-        start(
-              self.driver, '', 'ANY', session_name=None,
-              javascript_disabled=False,
-              assume_trusted_cert_issuer=False,
-              webdriver_remote=None)
+        start(self.browser_name)
         self.useFixture(LoggerSilencerFixture(self.logger_names))
         self.addCleanup(stop)
 
@@ -112,57 +113,159 @@
 project_home = dirname(dirname(dirname(dirname(__file__))))
 
 
-def get_drivers_from_env():
-    """Parse the environment variable MAAS_TEST_BROWSERS to get a list of
+def get_browser_names_from_env():
+    """Parse the environment variable ``MAAS_TEST_BROWSERS`` to get a list of
     the browsers to use for the JavaScript tests.
+
     Returns ['Firefox'] if the environment variable is not present.
     """
-    return map(
-        string.strip,
-        os.environ.get('MAAS_TEST_BROWSERS', 'Firefox').split(','))
-
-
-class TestYUIUnitTests(TestWithScenarios, TestCase):
-
-    scenarios = [
-        (driver, dict(driver=driver)) for driver in get_drivers_from_env()]
-
-    def setUp(self):
-        super(TestYUIUnitTests, self).setUp()
-        self.useFixture(DisplayFixture())
-        self.useFixture(SSTFixture(self.driver))
-
-    def _get_failed_tests_message(self, results):
-        """Return a readable error message with the list of the failed tests.
-
-        Given a YUI3 results_ json object, return a readable error message.
-
-        .. _results: http://yuilibrary.com/yui/docs/test/
+    names = os.environ.get('MAAS_TEST_BROWSERS', 'Firefox')
+    return extract_word_list(names)
+
+
+# See <https://saucelabs.com/docs/ondemand/browsers/env/python/se2/linux> for
+# more information on browser/platform choices.
+remote_browsers = {
+    "ie7": dict(
+        DesiredCapabilities.INTERNETEXPLORER,
+        version="7", platform="XP"),
+    "ie8": dict(
+        DesiredCapabilities.INTERNETEXPLORER,
+        version="8", platform="XP"),
+    "ie9": dict(
+        DesiredCapabilities.INTERNETEXPLORER,
+        version="9", platform="VISTA"),
+    "chrome": dict(
+        DesiredCapabilities.CHROME,
+        platform="VISTA"),
+    }
+
+
+def get_remote_browser_names_from_env():
+    """Parse the environment variable ``MAAS_REMOTE_TEST_BROWSERS`` to get a
+    list of the browsers to use for the JavaScript tests.
+
+    Returns [] if the environment variable is not present.
+    """
+    names = os.environ.get('MAAS_REMOTE_TEST_BROWSERS', '')
+    names = [name.lower() for name in extract_word_list(names)]
+    unrecognised = set(names).difference(remote_browsers)
+    if len(unrecognised) > 0:
+        raise ValueError("Unrecognised browsers: %r" % unrecognised)
+    return names
+
+
+@nottest
+def get_failed_tests_message(results):
+    """Return a readable error message with the list of the failed tests.
+
+    Given a YUI3 results_ json object, return a readable error message.
+
+    .. _results: http://yuilibrary.com/yui/docs/test/
+    """
+    result = []
+    suites = [item for item in results.values() if isinstance(item, dict)]
+    for suite in suites:
+        if suite['failed'] != 0:
+            tests = [item for item in suite.values()
+                     if isinstance(item, dict)]
+            for test in tests:
+                if test['result'] != 'pass':
+                    result.append('\n%s.%s: %s\n' % (
+                        suite['name'], test['name'], test['message']))
+    return ''.join(result)
+
+
+class YUIUnitBase:
+
+    __metaclass__ = ABCMeta
+
+    test_paths = glob(join(BASE_PATH, "*.html"))
+
+    # Indicates if this test has been cloned.
+    cloned = False
+
+    def clone(self, suffix):
+        # Clone this test with a new suffix.
+        test = clone_test_with_new_id(
+            self, "%s#%s" % (self.id(), suffix))
+        test.cloned = True
+        return test
+
+    @abstractmethod
+    def execute(self, result):
+        """Run the test for each of a specified range of browsers.
+
+        This method should sort out shared fixtures.
         """
-        result = []
-        suites = [item for item in results.values() if isinstance(item, dict)]
-        for suite in suites:
-            if suite['failed'] != 0:
-                tests = [item for item in suite.values()
-                         if isinstance(item, dict)]
-                for test in tests:
-                    if test['result'] != 'pass':
-                        result.append('\n%s.%s: %s\n' % (
-                            suite['name'], test['name'], test['message']))
-        return ''.join(result)
+
+    def __call__(self, result=None):
+        if self.cloned:
+            # This test has been cloned; just call-up to run the test.
+            super(YUIUnitBase, self).__call__(result)
+        else:
+            self.execute(result)
 
     def test_YUI3_unit_tests(self):
-        # Find all the HTML files in BASE_PATH.
-        for fname in os.listdir(BASE_PATH):
-            if fname.endswith('.html'):
-                # Load the page and then wait for #suite to contain
-                # 'done'.  Read the results in '#test_results'.
-                file_path = os.path.join(project_home, BASE_PATH, fname)
-                go_to('file://%s' % file_path)
-                wait_for(assert_text, 'suite', 'done')
-                results = json.loads(get_element(id='test_results').text)
-                if results['failed'] != 0:
-                    raise AssertionError(
-                        '%d test(s) failed.\n%s' % (
-                            results['failed'],
-                            self._get_failed_tests_message(results)))
+        # Load the page and then wait for #suite to contain
+        # 'done'.  Read the results in '#test_results'.
+        go_to(self.test_url)
+        wait_for(assert_text, 'suite', 'done')
+        results = json.loads(get_element(id='test_results').text)
+        if results['failed'] != 0:
+            message = '%d test(s) failed.\n%s' % (
+                results['failed'], get_failed_tests_message(results))
+            self.fail(message)
+
+
+class YUIUnitTestsLocal(YUIUnitBase, TestCase):
+
+    scenarios = tuple(
+        (path, {"test_url": "file://%s" % abspath(path)})
+        for path in YUIUnitBase.test_paths)
+
+    def execute(self, result):
+        # Run this test locally for each browser requested. Use the same
+        # display fixture for all browsers. This is done here so that all
+        # scenarios are played out for each browser in turn; starting and
+        # stopping browsers is costly.
+        with DisplayFixture():
+            for browser_name in get_browser_names_from_env():
+                browser_test = self.clone("local:%s" % browser_name)
+                with SSTFixture(browser_name):
+                    browser_test.__call__(result)
+
+
+class YUIUnitTestsRemote(YUIUnitBase, TestCase):
+
+    def execute(self, result):
+        # Now run this test remotely for each requested Sauce OnDemand
+        # browser requested.
+        browser_names = get_remote_browser_names_from_env()
+        if len(browser_names) == 0:
+            return
+
+        # A web server is needed so the OnDemand service can obtain local
+        # tests. Be careful when choosing web server ports:
+        #
+        #   Sauce Connect proxies localhost ports 80, 443, 888, 2000, 2001,
+        #   2020, 2222, 3000, 3001, 3030, 3333, 4000, 4001, 4040, 4502, 4503,
+        #   5000, 5001, 5050, 5555, 6000, 6001, 6060, 6666, 7000, 7070, 7777,
+        #   8000, 8001, 8003, 8080, 8888, 9000, 9001, 9090, 9999 so when you
+        #   use it, your local web apps are available to test as if the cloud
+        #   was your local machine. Easy!
+        #
+        # From <https://saucelabs.com/docs/ondemand/connect>.
+        with HTTPServerFixture(port=5555) as httpd:
+            scenarios = tuple(
+                (path, {"test_url": urljoin(httpd.url, path)})
+                for path in self.test_paths)
+            with SauceConnectFixture() as sauce_connect:
+                for browser_name in browser_names:
+                    capabilities = remote_browsers[browser_name]
+                    sst_ondemand = SSTOnDemandFixture(
+                        capabilities, sauce_connect.control_url)
+                    with sst_ondemand:
+                        browser_test = self.clone("remote:%s" % browser_name)
+                        browser_test.scenarios = scenarios
+                        browser_test(result)

=== added file 'src/maastesting/httpd.py'
--- src/maastesting/httpd.py	1970-01-01 00:00:00 +0000
+++ src/maastesting/httpd.py	2012-05-17 16:05:35 +0000
@@ -0,0 +1,53 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""HTTP server fixture."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "HTTPServerFixture",
+    ]
+
+from BaseHTTPServer import HTTPServer
+from SimpleHTTPServer import SimpleHTTPRequestHandler
+from SocketServer import ThreadingMixIn
+import threading
+
+from fixtures import Fixture
+
+
+class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
+    """A simple HTTP Server that whill run in it's own thread."""
+
+
+class SilentHTTPRequestHandler(SimpleHTTPRequestHandler):
+    # SimpleHTTPRequestHandler logs to stdout: silence it.
+    log_request = lambda *args, **kwargs: None
+    log_error = lambda *args, **kwargs: None
+
+
+class HTTPServerFixture(Fixture):
+    """Bring up a very simple, threaded, web server.
+
+    Files are served from the current working directory and below.
+    """
+
+    def __init__(self, host="localhost", port=0):
+        super(HTTPServerFixture, self).__init__()
+        self.server = ThreadingHTTPServer(
+            (host, port), SilentHTTPRequestHandler)
+
+    @property
+    def url(self):
+        return "http://%s:%d/"; % self.server.server_address
+
+    def setUp(self):
+        super(HTTPServerFixture, self).setUp()
+        threading.Thread(target=self.server.serve_forever).start()
+        self.addCleanup(self.server.shutdown)

=== added file 'src/maastesting/saucelabs.py'
--- src/maastesting/saucelabs.py	1970-01-01 00:00:00 +0000
+++ src/maastesting/saucelabs.py	2012-05-17 16:05:35 +0000
@@ -0,0 +1,229 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""SauceLabs' Sauce OnDemand fixtures."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "SauceConnectFixture",
+    "SauceOnDemandFixture",
+    "SSTOnDemandFixture",
+    "TimeoutException",
+    ]
+
+from contextlib import closing
+from io import BytesIO
+from itertools import (
+    chain,
+    islice,
+    repeat,
+    )
+from os import path
+import subprocess
+from urllib2 import urlopen
+from zipfile import ZipFile
+
+from fixtures import (
+    Fixture,
+    TempDir,
+    )
+from maastesting.utils import (
+    content_from_file,
+    extract_word_list,
+    preexec_fn,
+    retries,
+    )
+from selenium import webdriver
+from testtools.monkey import MonkeyPatcher
+
+
+sauce_connect_url = "https://saucelabs.com/downloads/Sauce-Connect-latest.zip";
+sauce_connect_dir = path.expanduser("~/.saucelabs/connect")
+
+
+def get_or_download_sauce_connect(url=sauce_connect_url):
+    """Find or download ``Sauce-Connect.jar`` to a shared location."""
+    sauce_connect_jarfile = path.join(
+        sauce_connect_dir, "Sauce-Connect.jar")
+    if not path.exists(sauce_connect_jarfile):
+        with closing(urlopen(url)) as fin:
+            buf = BytesIO(fin.read())
+        with ZipFile(buf) as zipfile:
+            zipfile.extractall(sauce_connect_dir)
+    return sauce_connect_jarfile
+
+
+def get_credentials():
+    """Load credentials for the SauceLabs Connect service.
+
+    @return: A ``(username, api_key)`` tuple.
+    """
+    sauce_connect_credentials_file = path.join(
+        sauce_connect_dir, "credentials")
+    with open(sauce_connect_credentials_file, "rb") as fd:
+        creds = extract_word_list(fd.read())
+    return tuple(islice(chain(creds, repeat(b"")), 2))
+
+
+class TimeoutException(Exception):
+    """An operation has timed-out."""
+
+
+class SauceConnectFixture(Fixture):
+    """Start up a Sauce Connect server.
+
+    See `Test Internal Sites`_.
+
+    .. _Test Internal Sites:
+      http://saucelabs.com/docs/ondemand/connect
+
+    """
+
+    def __init__(self, jarfile=None, credentials=None, control_port=4445):
+        """
+        @param jarfile: The path to the ``Sauce-Connect.jar`` file.
+        @param credentials: Credentials for the SauceLabs service, typically a
+            2-tuple of (username, api_key).
+        @param control_port: The port on which to accept Selenium commands.
+        """
+        super(SauceConnectFixture, self).__init__()
+        if jarfile is None:
+            jarfile = get_or_download_sauce_connect()
+        if credentials is None:
+            credentials = get_credentials()
+        self.jarfile = path.abspath(jarfile)
+        self.username, self.api_key = credentials
+        self.control_port = control_port
+
+    def setUp(self):
+        super(SauceConnectFixture, self).setUp()
+        self.workdir = self.useFixture(TempDir()).path
+        self.logfile = path.join(self.workdir, "connect.log")
+        self.readyfile = path.join(self.workdir, "ready")
+        self.command = (
+            "java", "-jar", self.jarfile,
+            self.username, self.api_key,
+            "--se-port", "%d" % self.control_port,
+            "--readyfile", self.readyfile)
+        self.start()
+        self.addCleanup(self.stop)
+
+    def start(self):
+        with open(path.devnull, "rb") as devnull:
+            with open(self.logfile, "wb", 1) as log:
+                self.addDetail(
+                    path.basename(self.logfile),
+                    content_from_file(self.logfile))
+                self.process = subprocess.Popen(
+                    self.command, stdin=devnull, stdout=log, stderr=log,
+                    cwd=self.workdir, preexec_fn=preexec_fn)
+        for elapsed, remaining in retries(120):
+            if self.process.poll() is None:
+                if path.isfile(self.readyfile):
+                    break
+            else:
+                raise subprocess.CalledProcessError(
+                    self.process.returncode, self.command)
+        else:
+            self.stop()
+            raise TimeoutException(
+                "%s took too long to start (more than %d seconds)" % (
+                    path.relpath(self.jarfile), elapsed))
+
+    def stop(self):
+        if self.process.poll() is None:
+            self.process.terminate()
+            for elapsed, remaining in retries(60):
+                returncode = self.process.poll()
+                # Sauce-Connect.jar appears to exit cleanly with code 143.
+                if returncode in (0, 143):
+                    break
+                if returncode is not None:
+                    raise subprocess.CalledProcessError(
+                        self.process.returncode, self.command)
+            else:
+                self.process.kill()
+                raise TimeoutException(
+                    "%s took too long to stop (more than %d seconds)" % (
+                        path.relpath(self.jarfile), elapsed))
+
+    @property
+    def control_url(self):
+        """URL for Selenium to connect to so that commands are proxied.
+
+        Possibly suitable for use with Selenium 2 only.
+        """
+        return "http://%s:%s@localhost:%d/wd/hub"; % (
+            self.username, self.api_key, self.control_port)
+
+
+class SauceOnDemandFixture(Fixture):
+    """Start up a driver for SauceLabs' Sauce OnDemand service.
+
+    See `Getting Started`_, the `Available Browsers List`_, and `Additional
+    Configuration`_ to help configure this fixture.
+
+    .. _Getting Started:
+      http://saucelabs.com/docs/ondemand/getting-started/env/python/se2/linux
+
+    .. _Available Browsers List:
+      http://saucelabs.com/docs/ondemand/browsers/env/python/se2/linux
+
+    .. _Additional Configuration:
+      http://saucelabs.com/docs/ondemand/additional-config
+
+    """
+
+    # Default capabilities
+    capabilities = {
+        "video-upload-on-pass": False,
+        "record-screenshots": False,
+        "record-video": False,
+        }
+
+    def __init__(self, capabilities, control_url):
+        """
+        @param capabilities: A member of `webdriver.DesiredCapabilities`, plus
+            any additional configuration.
+        @param control_url: The URL, including username and API key for the
+            Sauce OnDemand service, or a Sauce Connect service.
+        """
+        super(SauceOnDemandFixture, self).__init__()
+        self.capabilities = self.capabilities.copy()
+        self.capabilities.update(capabilities)
+        self.control_url = control_url
+
+    def setUp(self):
+        super(SauceOnDemandFixture, self).setUp()
+        self.driver = webdriver.Remote(
+            desired_capabilities=self.capabilities,
+            command_executor=self.control_url.encode("ascii"))
+        self.driver.implicitly_wait(30)  # TODO: Is this always needed?
+        self.addCleanup(self.driver.quit)
+
+
+class SSTOnDemandFixture(SauceOnDemandFixture):
+    """Variant of `SauceOnDemandFixture` that also patches `sst`.
+
+    When this fixture is active you can use `sst` with the allocated driver
+    without calling `sst.actions.start` or `sst.actions.stop`.
+    """
+
+    noop = staticmethod(lambda *args, **kwargs: None)
+
+    def setUp(self):
+        from sst import actions
+        super(SSTOnDemandFixture, self).setUp()
+        patcher = MonkeyPatcher(
+            (actions, "browser", self.driver),
+            (actions, "browsermob_proxy", None),
+            (actions, "start", self.noop),
+            (actions, "stop", self.noop))
+        self.addCleanup(patcher.restore)
+        patcher.patch()

=== modified file 'src/maastesting/testcase.py'
--- src/maastesting/testcase.py	2012-05-16 14:34:11 +0000
+++ src/maastesting/testcase.py	2012-05-17 16:05:35 +0000
@@ -14,15 +14,38 @@
     'TestCase',
     ]
 
+from contextlib import contextmanager
 import unittest
 
 from fixtures import TempDir
 from maastesting.factory import factory
 from maastesting.scenarios import WithScenarios
+from nose.proxy import ResultProxy
 import testresources
 import testtools
 
 
+@contextmanager
+def active_test(result, test):
+    """Force nose to report for the test that's running.
+
+    Nose presents a proxy result and passes on results using only the
+    top-level test, rather than the actual running test. This attempts to undo
+    this dubious choice.
+
+    If the result is not a nose proxy then this is a no-op.
+    """
+    if isinstance(result, ResultProxy):
+        orig = result.test.test
+        result.test.test = test
+        try:
+            yield
+        finally:
+            result.test.test = orig
+    else:
+        yield
+
+
 class TestCase(WithScenarios, testtools.TestCase):
     """Base `TestCase` for MAAS.
 
@@ -86,3 +109,11 @@
     # Django's implementation for this seems to be broken and was
     # probably only added to support compatibility with python 2.6.
     assertItemsEqual = unittest.TestCase.assertItemsEqual
+
+    def run(self, result=None):
+        with active_test(result, self):
+            super(TestCase, self).run(result)
+
+    def __call__(self, result=None):
+        with active_test(result, self):
+            super(TestCase, self).__call__(result)

=== added file 'src/maastesting/tests/test_httpd.py'
--- src/maastesting/tests/test_httpd.py	1970-01-01 00:00:00 +0000
+++ src/maastesting/tests/test_httpd.py	2012-05-17 16:05:35 +0000
@@ -0,0 +1,51 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `maastesting.httpd`."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from contextlib import closing
+from socket import (
+    gethostbyname,
+    gethostname,
+    )
+from urllib2 import urlopen
+from urlparse import urljoin
+
+from maastesting.httpd import (
+    HTTPServerFixture,
+    ThreadingHTTPServer,
+    )
+from maastesting.testcase import TestCase
+from testtools.matchers import FileExists
+
+
+class TestHTTPServerFixture(TestCase):
+
+    def test_init(self):
+        host = gethostname()
+        fixture = HTTPServerFixture(host=host)
+        self.assertIsInstance(fixture.server, ThreadingHTTPServer)
+        expected_url = "http://%s:%d/"; % (
+            gethostbyname(host), fixture.server.server_port)
+        self.assertEqual(expected_url, fixture.url)
+
+    def test_use(self):
+        filename = "setup.py"
+        self.assertThat(filename, FileExists())
+        with HTTPServerFixture() as httpd:
+            url = urljoin(httpd.url, filename)
+            with closing(urlopen(url)) as http_in:
+                with open(filename, "rb") as file_in:
+                    self.assertEqual(
+                        file_in.read(), http_in.read(),
+                        "The content of %s differs from %s." % (
+                            url, filename))

=== added file 'src/maastesting/tests/test_saucelabs.py'
--- src/maastesting/tests/test_saucelabs.py	1970-01-01 00:00:00 +0000
+++ src/maastesting/tests/test_saucelabs.py	2012-05-17 16:05:35 +0000
@@ -0,0 +1,317 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `maastesting.saucelabs`."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from os import (
+    devnull,
+    path,
+    )
+import subprocess
+
+from fixtures import Fixture
+from maastesting import saucelabs
+from maastesting.factory import factory
+from maastesting.saucelabs import (
+    get_credentials,
+    SauceConnectFixture,
+    SauceOnDemandFixture,
+    SSTOnDemandFixture,
+    TimeoutException,
+    )
+from maastesting.testcase import TestCase
+from selenium import webdriver
+from sst import actions
+from testtools.matchers import (
+    DirExists,
+    Not,
+    )
+
+
+def touch(filename):
+    open(filename, "ab").close()
+
+
+def one_retry(timeout, delay=1):
+    """Testing variant of `retries` that iterates once."""
+    yield timeout, 0
+
+
+def make_SauceConnectFixture(
+    jarfile=None, credentials=None, control_port=None):
+    """
+    Create a `SauceConnectFixture`, using random values unless specified
+    otherwise.
+    """
+    if jarfile is None:
+        jarfile = factory.getRandomString()
+    if credentials is None:
+        credentials = factory.getRandomString(), factory.getRandomString()
+    if control_port is None:
+        control_port = factory.getRandomPort()
+    return SauceConnectFixture(
+        jarfile=jarfile, credentials=credentials,
+        control_port=control_port)
+
+
+class FakeProcess:
+    """A rudimentary fake for `subprocess.Popen`."""
+
+    returncode = None
+
+    def __init__(self, *args, **kwargs):
+        self.args = args
+        self.kwargs = kwargs
+        self.events = []
+
+    def poll(self):
+        return self.returncode
+
+    def terminate(self):
+        self.events.append("terminate")
+
+    def kill(self):
+        self.events.append("kill")
+
+
+class TestSauceConnectFixture(TestCase):
+
+    def test_init(self):
+        port = factory.getRandomPort()
+        fixture = make_SauceConnectFixture(
+            "path/to/jar", ("jaz", "youth"), port)
+        self.assertEqual(path.abspath("path/to/jar"), fixture.jarfile)
+        self.assertEqual("jaz", fixture.username)
+        self.assertEqual("youth", fixture.api_key)
+        self.assertEqual(port, fixture.control_port)
+
+    def test_setUp_and_cleanUp(self):
+        calls = []
+        port = factory.getRandomPort()
+        fixture = make_SauceConnectFixture(
+            "path/to/jar", ("jaz", "youth"), port)
+        self.patch(fixture, "start", lambda: calls.append("start"))
+        self.patch(fixture, "stop", lambda: calls.append("stop"))
+        # Setting up the fixture allocates a working directory, the command to
+        # run, and calls start().
+        fixture.setUp()
+        self.assertThat(fixture.workdir, DirExists())
+        self.assertEqual(
+            "connect.log", path.relpath(
+                fixture.logfile, fixture.workdir))
+        self.assertEqual(
+            "ready", path.relpath(
+                fixture.readyfile, fixture.workdir))
+        self.assertEqual(
+            ("java", "-jar", path.abspath("path/to/jar"), "jaz", "youth",
+             "--se-port", "%d" % port, "--readyfile", fixture.readyfile),
+            fixture.command)
+        self.assertEqual(["start"], calls)
+        # Tearing down the fixture calls stop() and removes the working
+        # directory.
+        fixture.cleanUp()
+        self.assertThat(fixture.workdir, Not(DirExists()))
+        self.assertEqual(["start", "stop"], calls)
+
+    def test_start(self):
+        fixture = make_SauceConnectFixture()
+        start = fixture.start
+        self.patch(fixture, "start", lambda: None)
+        self.patch(fixture, "stop", lambda: None)
+        self.patch(subprocess, "Popen", FakeProcess)
+        fixture.setUp()
+        # Create the readyfile to simulate a successful start.
+        touch(fixture.readyfile)
+        # Use the real start() method.
+        start()
+        self.assertEqual((fixture.command,), fixture.process.args)
+        kwargs = fixture.process.kwargs
+        self.assertEqual(fixture.workdir, kwargs["cwd"])
+        self.assertIs(saucelabs.preexec_fn, kwargs["preexec_fn"])
+        self.assertEqual(devnull, kwargs["stdin"].name)
+        self.assertEqual(fixture.logfile, kwargs["stdout"].name)
+        self.assertEqual(fixture.logfile, kwargs["stderr"].name)
+        self.assertEqual([], fixture.process.events)
+
+    def test_start_failure(self):
+        fixture = make_SauceConnectFixture()
+        start = fixture.start
+        self.patch(fixture, "start", lambda: None)
+        self.patch(fixture, "stop", lambda: None)
+        self.patch(subprocess, "Popen", FakeProcess)
+        # Pretend that processes immediately fail with return code 1.
+        self.patch(FakeProcess, "returncode", 1)
+        fixture.setUp()
+        error = self.assertRaises(subprocess.CalledProcessError, start)
+        self.assertEqual(1, error.returncode)
+        self.assertEqual(fixture.command, error.cmd)
+        self.assertEqual([], fixture.process.events)
+
+    def test_start_timeout(self):
+        calls = []
+        fixture = make_SauceConnectFixture()
+        start = fixture.start
+        self.patch(fixture, "start", lambda: None)
+        self.patch(fixture, "stop", lambda: calls.append("stop"))
+        self.patch(subprocess, "Popen", FakeProcess)
+        self.patch(saucelabs, "retries", one_retry)
+        fixture.setUp()
+        self.assertRaises(TimeoutException, start)
+        # stop() has also been called.
+        self.assertEqual(["stop"], calls)
+
+    def test_stop(self):
+
+        def terminate():
+            # Simulate a successful stop.
+            fixture.process.returncode = 0
+
+        fixture = make_SauceConnectFixture()
+        fixture.process = FakeProcess()
+        fixture.process.terminate = terminate
+        fixture.stop()
+        # terminate() has been called during shutdown.
+        self.assertEqual(0, fixture.process.returncode)
+
+    def test_stop_failure(self):
+
+        def terminate():
+            # Simulate a failure.
+            fixture.process.returncode = 34
+
+        fixture = make_SauceConnectFixture()
+        fixture.process = FakeProcess()
+        fixture.process.terminate = terminate
+        fixture.command = object()
+        error = self.assertRaises(subprocess.CalledProcessError, fixture.stop)
+        self.assertEqual(34, error.returncode)
+        self.assertEqual(fixture.command, error.cmd)
+        self.assertEqual([], fixture.process.events)
+
+    def test_stop_timeout(self):
+        fixture = make_SauceConnectFixture()
+        fixture.process = FakeProcess()
+        fixture.command = object()
+        self.patch(saucelabs, "retries", one_retry)
+        self.assertRaises(TimeoutException, fixture.stop)
+        # terminate() and kill() were both called to ensure shutdown.
+        self.assertEqual(["terminate", "kill"], fixture.process.events)
+
+    def test_control_url(self):
+        fixture = make_SauceConnectFixture(
+            credentials=("scott", "ian"), control_port=6456)
+        self.assertEqual(
+            "http://scott:ian@localhost:6456/wd/hub";,
+            fixture.control_url)
+
+
+class TestSauceOnDemandFixture(TestCase):
+
+    def test_init(self):
+        # Default capabilities are added into the given ones.
+        url = "http://het:field@localhost/lars/";
+        fixture = SauceOnDemandFixture({1: 2}, url)
+        capabilities_default = SauceOnDemandFixture.capabilities
+        capabilities_expected = capabilities_default.copy()
+        capabilities_expected[1] = 2
+        self.assertEqual(capabilities_expected, fixture.capabilities)
+        self.assertEqual(url, fixture.control_url)
+
+    def test_init_override_capabilities(self):
+        # Capabilities passed in override the defaults.
+        capabilities_override = {
+            name: factory.getRandomString()
+            for name in SauceOnDemandFixture.capabilities
+            }
+        fixture = SauceOnDemandFixture(
+            capabilities_override, factory.getRandomString())
+        self.assertEqual(capabilities_override, fixture.capabilities)
+
+    def test_setUp_and_cleanUp(self):
+        calls = []
+        capabilities = webdriver.DesiredCapabilities.FIREFOX.copy()
+        url = "http://het:field@127.0.0.1/lars";
+        fixture = SauceOnDemandFixture(capabilities, url)
+
+        def start_session(driver, desired_capabilities, browser_profile=None):
+            self.assertEqual(fixture.capabilities, desired_capabilities)
+            calls.append("start_session")
+
+        def quit(driver):
+            calls.append("quit")
+
+        def execute(driver, driver_command, params=None):
+            pass  # Don't make any HTTP calls.
+
+        self.patch(webdriver.Remote, "start_session", start_session)
+        self.patch(webdriver.Remote, "quit", quit)
+        self.patch(webdriver.Remote, "execute", execute)
+
+        with fixture:
+            self.assertIsInstance(fixture.driver, webdriver.Remote)
+            self.assertEqual(url, fixture.driver.command_executor._url)
+            self.assertEqual(["start_session"], calls)
+        self.assertEqual(["start_session", "quit"], calls)
+
+
+class TestSSTOnDemandFixture(TestSauceOnDemandFixture):
+
+    def test_patch_into_sst(self):
+        fixture = SSTOnDemandFixture({}, "")
+        fixture.driver = object()
+        # Disable SauceOnDemandFixture's setUp so we can see what
+        # SSTOnDemandFixture's is doing.
+        self.patch(SauceOnDemandFixture, "setUp", Fixture.setUp)
+        # Use a sentinel to help demonstrate the changes.
+        sentinel = object()
+        self.patch(actions, "browser", sentinel)
+        self.patch(actions, "browsermob_proxy", sentinel)
+        self.patch(actions, "start", sentinel)
+        self.patch(actions, "stop", sentinel)
+        # When the fixture is active the browser is set to the driver that
+        # SauceOnDemandFixture.setUp() arranges, the proxy is unset, and the
+        # start() and stop() functions are disabled.
+        with fixture:
+            self.assertIs(fixture.driver, actions.browser)
+            self.assertIs(None, actions.browsermob_proxy)
+            self.assertIs(SSTOnDemandFixture.noop, actions.start)
+            self.assertIs(SSTOnDemandFixture.noop, actions.stop)
+        # When the fixture deactivates things return to what they were before.
+        self.assertIs(sentinel, actions.browser)
+        self.assertIs(sentinel, actions.browsermob_proxy)
+        self.assertIs(sentinel, actions.start)
+        self.assertIs(sentinel, actions.stop)
+
+
+class TestFunctions(TestCase):
+
+    def patch_creds_file(self, contents):
+        creds_file = self.make_file("creds", contents.encode("ascii"))
+        self.patch(saucelabs, "sauce_connect_dir", path.dirname(creds_file))
+
+    def test_get_credentials(self):
+        self.patch_creds_file("metal licker")
+        self.assertEqual(("metal", "licker"), get_credentials())
+
+    def test_get_credentials_missing(self):
+        self.patch(saucelabs, "sauce_connect_dir", self.make_dir())
+        self.assertRaises(IOError, get_credentials)
+
+    def test_get_credentials_too_many_words(self):
+        # Only the first two words found in the credentials file are returned.
+        self.patch_creds_file("kill the lights")
+        self.assertEqual(("kill", "the"), get_credentials())
+
+    def test_get_credentials_too_few_words(self):
+        # Missing words are returned as the empty string.
+        self.patch_creds_file("kirk")
+        self.assertEqual(("kirk", ""), get_credentials())

=== added file 'src/maastesting/tests/test_utils.py'
--- src/maastesting/tests/test_utils.py	1970-01-01 00:00:00 +0000
+++ src/maastesting/tests/test_utils.py	2012-05-17 16:05:35 +0000
@@ -0,0 +1,34 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for testing helpers."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = []
+
+from maastesting.testcase import TestCase
+from maastesting.utils import extract_word_list
+
+
+class TestFunctions(TestCase):
+
+    def test_extract_word_list(self):
+        expected = {
+            "one 2": ["one", "2"],
+            ", one ; 2": ["one", "2"],
+            "one,2": ["one", "2"],
+            "one;2": ["one", "2"],
+            "\none\t 2;": ["one", "2"],
+            "\none-two\t 3;": ["one-two", "3"],
+            }
+        observed = {
+            string: extract_word_list(string)
+            for string in expected
+            }
+        self.assertEqual(expected, observed)

=== added file 'src/maastesting/utils.py'
--- src/maastesting/utils.py	1970-01-01 00:00:00 +0000
+++ src/maastesting/utils.py	2012-05-17 16:05:35 +0000
@@ -0,0 +1,76 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Testing utilities."""
+
+from __future__ import (
+    absolute_import,
+    print_function,
+    unicode_literals,
+    )
+
+__metaclass__ = type
+__all__ = [
+    "content_from_file",
+    "extract_word_list",
+    "preexec_fn",
+    "retries",
+    ]
+
+import re
+import signal
+from time import (
+    sleep,
+    time,
+    )
+
+from testtools.content import Content
+from testtools.content_type import UTF8_TEXT
+
+
+def content_from_file(path):
+    """Alternative to testtools' version.
+
+    This keeps an open file-handle, so it can obtain the log even when the
+    file has been unlinked.
+    """
+    fd = open(path, "rb")
+
+    def iterate():
+        fd.seek(0)
+        return iter(fd)
+
+    return Content(UTF8_TEXT, iterate)
+
+
+def extract_word_list(string):
+    """Return a list of words from a string.
+
+    Words are any string of 1 or more characters, not including commas,
+    semi-colons, or whitespace.
+    """
+    return re.findall("[^,;\s]+", string)
+
+
+def preexec_fn():
+    # Revert Python's handling of SIGPIPE. See
+    # http://bugs.python.org/issue1652 for more info.
+    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
+
+
+def retries(timeout=30, delay=1):
+    """Helper for retrying something, sleeping between attempts.
+
+    Yields ``(elapsed, remaining)`` tuples, giving times in seconds.
+
+    @param timeout: From now, how long to keep iterating, in seconds.
+    @param delay: The sleep between each iteration, in seconds.
+    """
+    start = time()
+    end = start + timeout
+    for now in iter(time, None):
+        if now < end:
+            yield now - start, end - now
+            sleep(min(delay, end - now))
+        else:
+            break


Follow ups