launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #07976
[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