launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #05039
[Merge] lp:~gary/launchpad/yuixhr into lp:launchpad
Gary Poster has proposed merging lp:~gary/launchpad/yuixhr into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~gary/launchpad/yuixhr/+merge/76315
[This is a very big branch of work started back in the Dublin sprint. I will be looking for a reviewer from the group who worked on it then, assuming I do not get someone else willing to tackle something of this size for a lark. I thought of ways to divide it, but the majority of it is of a piece, and I decided it would be better to get a full review. I'm happy to talk about the division possibilities if you disagree, or even if you wonder about it.]
This branch adds the ability to write Javascript integration tests: YUI unit tests that have a full appserver ready behind it. These should be used sparingly and carefully. The majority of our Javascript tests should continue to be in the pre-existing Javascript-only layer. However, this fills a real need. These sort of tests are intended to replace any need for our old Windmill tests. Selenium tests may be added in the future but only as a way to write acceptance tests.
I suggest starting with the two files lib/lp/testing/tests/test_standard_yuixhr_test_template.js and lib/lp/testing/tests/test_standard_yuixhr_test_template.py. These are also exposed at the top of the tree, via symlink, as exemplars (standard_yuixhr_test_template.js and standard_yuixhr_test_template.py). They are supposed to show how to write these tests, and generally show their capabilities. I considered whether these files should be smaller, with the information within them moved to the wiki. I decided I preferred this approach, but would be happy to discuss alternates. In any case, reading these files should give you an idea of how this branch is supposed to work.
You can run the existing tests in the new layer with
xvfb-run ./bin/test -vvc --layer=YUIAppServerLayer
You can run them interactively by running "make run-testapp", waiting about 20 seconds (note that there is no warning on the console when it is ready; just retry in the browser) and then visiting http://launchpad.dev:8085/+yuitest/lp/testing/tests/test_standard_yuixhr_test_template and http://launchpad.dev:8085/+yuitest/lp/testing/tests/test_yuixhr_fixture .
The other major new tests of functionality can be run with
./bin/test -vvc lp.testing.tests.test_yuixhr TestYUITestFixtureController
The people who worked on this were myself, Francis, Curtis, and Deryck, with an initial assist from Steven. I'll count them as my "pre-imp call".
== Commentary ==
I'll now provide commentary on the rest of the files that I think could use discussion.
yuixhr.py and test_yuixhr.py are the heart of the matter. I've/we've tried to comment and name them sufficiently. The use of the special response object to reset the database outside of transactions is the trickiest aspect of it.
Some of the yuixhr functionality was easier to test in the tests of the Javascript integration itself. These, along with the tests for the YUI module hookup server_fixture.js, can be found in the linked pair of test_yuixhr_fixture.js and test_yuixhr_fixture.py.
Everything else is supporting material of one sort or another.
The two yuitest.zcml files add the +yuitest view that enables these tests. They are added only for tests or "make run-testapp", not for normal devel or any production.
The Makefile changes add a "run-testapp" target that we can use to run yuixhr tests interactively.
runlaunchpad.py contains the code used to actually start the app server for running the tests in the test suite and interactively. I intend for the comments to be sufficient, so I'll leave them to speak for themselves. This represents a fair amount of tweaking to find a relatively clean approach to the problem; I'll talk about alternatives I tried and considered if requested. I was reasonably pleased with the end result and hope you are. setup.py exposes this target for the test suite and the interactive "run-testapp" target.
The layers.py has one of a very few fly-by fixes: when you run some tests on layers that did not set up an LP_TEST_INSTANCE, the layer teardown would complain unnecessarily. This would only affect isolated test runs, not a full test run. I cleaned up that complaint. It also includes the YUIAppServerLayer. I initially did this more like the existing appserver layer, with creating the database and librarian in the parent process. Comments in runlaunchpad.py hint at why I moved away from this approach; again, I can go in depth if requested.
The changes to lib/lp/testing/__init__.py take Curtis' work to run a browser for our normal Javascript tests and adjust it so that I can use it for these tests as well. I initially expected to follow even more of his patterns--generating a test suite on the fly--but because these tests have .py files, the .py files are the natural places to hook things up for the test suite. Doing otherwise has problems, which I can, again, discuss further if desired.
test_login.py and _login.py make it possible to have nested logins when using factories, which can be important for situations in which you want the browser logged in as one person but a factory working as another. This change could be proposed separately, but it was such a drop in the bucket that I didn't really see the point.
I may have missed something in this description, but that's at least a broad-brush introduction.
== Lint ==
Unfortunately, make lint is not entirely happy, but I've fixed everything I agree with it. Here's what's left, with commentary.
./Makefile
273: Line exceeds 78 characters.
This existed before I came along and doesn't have anything to do with my changes.
./standard_yuixhr_test_template.py
103: redefinition of function 'example' from line 39
102: E302 expected 2 blank lines, found 0
This is because of the way the decorators work--which follows the @x.setter property pattern (http://docs.python.org/library/functions.html#property).
./lib/lp/testing/yuixhr.py
288: local variable '__traceback_info__' is assigned to but never used
This is information in the frame for traceback rendering as done by zope.exceptions and lp.services.stacktrace. If something goes wrong, you can know what the data object looked like at the time and which fixture was running.
./lib/lp/testing/tests/test_login.py
279: E301 expected 1 blank line, found 0
288: E301 expected 1 blank line, found 0
These are for example functions defined within a method. I believe they existed before I came along, and I don't have a problem with them as is, so I left them.
./lib/lp/testing/tests/test_standard_yuixhr_test_template.py
103: redefinition of function 'example' from line 39
102: E302 expected 2 blank lines, found 0
This is actually the same file (symlinked) as ./standard_yuixhr_test_template.py, discussed above.
./lib/lp/testing/tests/test_yuixhr_fixture.py
32: redefinition of function 'baseline' from line 27
41: redefinition of function 'second' from line 38
54: redefinition of function 'faux_database_thing' from line 49
63: redefinition of function 'show_teardown_value' from line 60
91: redefinition of function 'teardown_will_fail' from line 88
40: E302 expected 2 blank lines, found 0
53: E302 expected 2 blank lines, found 0
62: E302 expected 2 blank lines, found 0
90: E302 expected 2 blank lines, found 0
These are all examples of the same problem discussed in ./standard_yuixhr_test_template.py.
Thank you!
Gary
--
https://code.launchpad.net/~gary/launchpad/yuixhr/+merge/76315
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~gary/launchpad/yuixhr into lp:launchpad.
=== modified file 'Makefile'
--- Makefile 2011-09-13 14:15:40 +0000
+++ Makefile 2011-09-21 01:29:24 +0000
@@ -60,8 +60,8 @@
bin/i18ncompile bin/i18nextract bin/i18nmergeall bin/i18nstats \
bin/harness bin/iharness bin/ipy bin/jsbuild \
bin/killservice bin/kill-test-services bin/lint.sh bin/retest \
- bin/run bin/sprite-util bin/start_librarian bin/stxdocs bin/tags \
- bin/test bin/tracereport bin/twistd bin/update-download-cache
+ bin/run bin/run-testapp bin/sprite-util bin/start_librarian bin/stxdocs \
+ bin/tags bin/test bin/tracereport bin/twistd bin/update-download-cache
BUILDOUT_TEMPLATES = buildout-templates/_pythonpath.py.in
@@ -255,6 +255,11 @@
run: build inplace stop
bin/run -r librarian,google-webservice,memcached,rabbitmq -i $(LPCONFIG)
+run-testapp: LPCONFIG=testrunner-appserver
+run-testapp: build inplace stop
+ LPCONFIG=$(LPCONFIG) INTERACTIVE_TESTS=1 bin/run-testapp \
+ -r memcached -i $(LPCONFIG)
+
run.gdb:
echo 'run' > run.gdb
@@ -460,12 +465,11 @@
--docformat restructuredtext --verbose-about epytext-summary \
$(PYDOCTOR_OPTIONS)
-.PHONY: \
- apidoc build_eggs buildonce_eggs buildout_bin check check \
- check_config check_mailman clean clean_buildout clean_js \
- clean_logs compile css_combine debug default doc ftest_build \
- ftest_inplace hosted_branches jsbuild jsbuild_widget_css \
- launchpad.pot pagetests pull_branches pydoctor realclean \
- reload-apache run scan_branches schema sprite_css sprite_image \
- start stop sync_branches TAGS tags test_build test_inplace \
- zcmldocs
+.PHONY: apidoc build_eggs buildonce_eggs buildout_bin check check \
+ check_config check_mailman clean clean_buildout clean_js \
+ clean_logs compile css_combine debug default doc ftest_build \
+ ftest_inplace hosted_branches jsbuild jsbuild_widget_css \
+ launchpad.pot pagetests pull_branches pydoctor realclean \
+ reload-apache run run-testapp runner scan_branches schema \
+ sprite_css sprite_image start stop sync_branches TAGS tags \
+ test_build test_inplace zcmldocs
=== renamed file 'configs/testrunner-appserver/yui-unittest.zcml' => 'configs/testrunner-appserver/yuitest.zcml'
--- configs/testrunner-appserver/yui-unittest.zcml 2010-03-22 18:18:11 +0000
+++ configs/testrunner-appserver/yuitest.zcml 2011-09-21 01:29:24 +0000
@@ -7,9 +7,9 @@
xmlns:browser="http://namespaces.zope.org/browser">
<browser:page
- name="+yui-unittest"
+ name="+yuitest"
for="canonical.launchpad.webapp.interfaces.ILaunchpadRoot"
- class="lp.testing.views.YUITestFileView"
+ class="lp.testing.yuixhr.YUITestFixtureControllerView"
attribute="__call__"
permission="zope.Public"/>
=== added file 'configs/testrunner/yuitest.zcml'
--- configs/testrunner/yuitest.zcml 1970-01-01 00:00:00 +0000
+++ configs/testrunner/yuitest.zcml 2011-09-21 01:29:24 +0000
@@ -0,0 +1,16 @@
+<!-- Copyright 2010 Canonical Ltd. This software is licensed under the
+ GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure
+ xmlns="http://namespaces.zope.org/zope"
+ xmlns:browser="http://namespaces.zope.org/browser">
+
+ <browser:page
+ name="+yuitest"
+ for="canonical.launchpad.webapp.interfaces.ILaunchpadRoot"
+ class="lp.testing.yuixhr.YUITestFixtureControllerView"
+ attribute="__call__"
+ permission="zope.Public"/>
+
+</configure>
=== modified file 'lib/canonical/launchpad/scripts/runlaunchpad.py'
--- lib/canonical/launchpad/scripts/runlaunchpad.py 2011-09-15 11:00:28 +0000
+++ lib/canonical/launchpad/scripts/runlaunchpad.py 2011-09-21 01:29:24 +0000
@@ -318,7 +318,62 @@
return args
-def start_launchpad(argv=list(sys.argv)):
+def start_testapp(argv=list(sys.argv)):
+ from canonical.testing.layers import (
+ BaseLayer,
+ DatabaseLayer,
+ LayerProcessController,
+ LibrarianLayer,
+ RabbitMQLayer,
+ )
+ from lp.testing.pgsql import (
+ installFakeConnect,
+ uninstallFakeConnect,
+ )
+ assert config.instance_name.startswith('testrunner-appserver'), (
+ '%r does not start with "testrunner-appserver"' %
+ config.instance_name)
+ interactive_tests = 'INTERACTIVE_TESTS' in os.environ
+
+ def setup():
+ # This code needs to be run after other zcml setup happens in
+ # runlaunchpad, so it is passed in as a callable.
+ BaseLayer.setUp()
+ if interactive_tests:
+ # The test suite runs its own RabbitMQ. We only need this
+ # for interactive tests. We set it up here rather than by
+ # passing it in as an argument to start_launchpad because
+ # the appserver config does not normally need/have
+ # RabbitMQ config set.
+ RabbitMQLayer.setUp()
+ # We set up the database here even for the test suite because we want
+ # to be able to control the database here in the subprocess. It is
+ # possible to do that when setting the database up in the parent
+ # process, but it is messier. This is simple.
+ installFakeConnect()
+ DatabaseLayer.setUp()
+ # The Librarian needs access to the database, so setting it up here
+ # where we are setting up the database makes the most sense.
+ LibrarianLayer.setUp()
+ # Interactive tests always need this. We let functional tests use
+ # a local one too because of simplicity.
+ LayerProcessController.startSMTPServer()
+ try:
+ start_launchpad(argv, setup)
+ finally:
+ LayerProcessController.stopSMTPServer()
+ LibrarianLayer.tearDown()
+ DatabaseLayer.tearDown()
+ uninstallFakeConnect()
+ if interactive_tests:
+ try:
+ RabbitMQLayer.tearDown()
+ except NotImplementedError:
+ pass
+ BaseLayer.tearDown()
+
+
+def start_launchpad(argv=list(sys.argv), setup=None):
# We really want to replace this with a generic startup harness.
# However, this should last us until this is developed
services, argv = split_out_runlaunchpad_arguments(argv[1:])
@@ -326,21 +381,22 @@
services = get_services_to_run(services)
# Create the ZCML override file based on the instance.
config.generate_overrides()
-
# Many things rely on a directory called 'logs' existing in the current
# working directory.
ensure_directory_exists('logs')
+ if setup is not None:
+ # This is the setup from start_testapp, above.
+ setup()
with nested(*services):
# Store our process id somewhere
make_pidfile('launchpad')
-
if config.launchpad.launch:
main(argv)
else:
# We just need the foreground process to sit around forever
- # waiting for the signal to shut everything down. Normally, Zope
- # itself would be this master process, but we're not starting that
- # up, so we need to do something else.
+ # waiting for the signal to shut everything down. Normally,
+ # Zope itself would be this master process, but we're not
+ # starting that up, so we need to do something else.
try:
signal.pause()
except KeyboardInterrupt:
=== modified file 'lib/canonical/testing/ftests/test_layers.py'
--- lib/canonical/testing/ftests/test_layers.py 2011-07-04 01:56:33 +0000
+++ lib/canonical/testing/ftests/test_layers.py 2011-09-21 01:29:24 +0000
@@ -546,7 +546,7 @@
def test_stopAppServer(self):
# Test that stopping the app server kills the process and remove the
# PID file.
- LayerProcessController._setConfig()
+ LayerProcessController.setConfig()
LayerProcessController.startAppServer()
pid = LayerProcessController.appserver.pid
pid_file = pidfile_path('launchpad',
@@ -560,7 +560,7 @@
def test_postTestInvariants(self):
# A LayerIsolationError should be raised if the app server dies in the
# middle of a test.
- LayerProcessController._setConfig()
+ LayerProcessController.setConfig()
LayerProcessController.startAppServer()
pid = LayerProcessController.appserver.pid
os.kill(pid, signal.SIGTERM)
@@ -570,12 +570,12 @@
def test_postTestInvariants_dbIsReset(self):
# The database should be reset by the test invariants.
- LayerProcessController._setConfig()
+ LayerProcessController.setConfig()
LayerProcessController.startAppServer()
LayerProcessController.postTestInvariants()
# XXX: Robert Collins 2010-10-17 bug=661967 - this isn't a reset, its
# a flag that it *needs* a reset, which is actually quite different;
- # the lack of a teardown will leak daabases.
+ # the lack of a teardown will leak databases.
self.assertEquals(True, LaunchpadTestSetup()._reset_db)
=== modified file 'lib/canonical/testing/layers.py'
--- lib/canonical/testing/layers.py 2011-09-18 13:04:13 +0000
+++ lib/canonical/testing/layers.py 2011-09-21 01:29:24 +0000
@@ -44,6 +44,7 @@
'TwistedLaunchpadZopelessLayer',
'TwistedLayer',
'YUITestLayer',
+ 'YUIAppServerLayer',
'ZopelessAppServerLayer',
'ZopelessDatabaseLayer',
'ZopelessLayer',
@@ -755,8 +756,9 @@
cls.force_dirty_database()
cls._db_fixture.tearDown()
cls._db_fixture = None
- cls._db_template_fixture.tearDown()
- cls._db_template_fixture = None
+ if os.environ.get('LP_TEST_INSTANCE'):
+ cls._db_template_fixture.tearDown()
+ cls._db_template_fixture = None
@classmethod
@profiled
@@ -1745,14 +1747,14 @@
smtp_controller = None
@classmethod
- def _setConfig(cls):
+ def setConfig(cls):
"""Stash a config for use."""
cls.appserver_config = CanonicalConfig(
BaseLayer.appserver_config_name, 'runlaunchpad')
@classmethod
def setUp(cls):
- cls._setConfig()
+ cls.setConfig()
cls.startSMTPServer()
cls.startAppServer()
@@ -1778,12 +1780,12 @@
@classmethod
@profiled
- def startAppServer(cls):
+ def startAppServer(cls, run_name='run'):
"""Start the app server if it hasn't already been started."""
if cls.appserver is not None:
raise LayerInvariantError('App server already running')
cls._cleanUpStaleAppServer()
- cls._runAppServer()
+ cls._runAppServer(run_name)
cls._waitUntilAppServerIsReady()
@classmethod
@@ -1875,11 +1877,11 @@
pidfile.remove_pidfile('launchpad', cls.appserver_config)
@classmethod
- def _runAppServer(cls):
+ def _runAppServer(cls, run_name):
"""Start the app server using runlaunchpad.py"""
_config = cls.appserver_config
cmd = [
- os.path.join(_config.root, 'bin', 'run'),
+ os.path.join(_config.root, 'bin', run_name),
'-C', 'configs/%s/launchpad.conf' % _config.instance_name]
environ = dict(os.environ)
environ['LPCONFIG'] = _config.instance_name
@@ -1888,10 +1890,14 @@
env=environ, cwd=_config.root)
@classmethod
+ def appserver_root_url(cls):
+ return cls.appserver_config.vhost.mainsite.rooturl
+
+ @classmethod
def _waitUntilAppServerIsReady(cls):
"""Wait until the app server accepts connection."""
assert cls.appserver is not None, "App server isn't started."
- root_url = cls.appserver_config.vhost.mainsite.rooturl
+ root_url = cls.appserver_root_url()
until = datetime.datetime.now() + WAIT_INTERVAL
while until > datetime.datetime.now():
try:
@@ -2001,4 +2007,24 @@
class YUITestLayer(FunctionalLayer):
- """The base class for all YUITests cases."""
+ """The layer for all YUITests cases."""
+
+
+class YUIAppServerLayer(MemcachedLayer, RabbitMQLayer):
+ """The layer for all YUIAppServer test cases."""
+
+ @classmethod
+ @profiled
+ def setUp(cls):
+ LayerProcessController.setConfig()
+ LayerProcessController.startAppServer('run-testapp')
+
+ @classmethod
+ @profiled
+ def tearDown(cls):
+ LayerProcessController.stopAppServer()
+
+ @classmethod
+ @profiled
+ def testSetUp(cls):
+ LaunchpadLayer.resetSessionDb()
=== added file 'lib/lp/app/javascript/server_fixture.js'
--- lib/lp/app/javascript/server_fixture.js 1970-01-01 00:00:00 +0000
+++ lib/lp/app/javascript/server_fixture.js 2011-09-21 01:29:24 +0000
@@ -0,0 +1,65 @@
+/* Copyright 2011 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Code to support full application server testing with YUI.
+ *
+ * @module Y.lp.testing.serverfixture
+ */
+YUI.add('lp.testing.serverfixture', function(Y) {
+
+var module = Y.namespace('lp.testing.serverfixture');
+
+/*
+ * This function calls fixture on the appserver side.
+ */
+module.setup = function(testcase) {
+ // self-post, get data, stash/merge on testcase
+ var fixtures = Y.Array(arguments, 1);
+ var data = Y.QueryString.stringify(
+ {action: 'setup',
+ fixtures: fixtures.join(',')
+ });
+ var config = {
+ method: "POST",
+ data: data,
+ sync: true,
+ headers: {Accept: 'application/json'}
+ };
+ var response = Y.io(window.location, config);
+ if (response.status !== 200) {
+ Y.error(response.responseText);
+ }
+ data = Y.JSON.parse(response.responseText);
+ if (!Y.Lang.isValue(testcase._lp_fixture_setups)) {
+ testcase._lp_fixture_setups = [];
+ }
+ testcase._lp_fixture_setups = testcase._lp_fixture_setups.concat(
+ fixtures);
+ if (!Y.Lang.isValue(testcase._lp_fixture_data)) {
+ testcase._lp_fixture_data = {};
+ }
+ testcase._lp_fixture_data = Y.merge(testcase._lp_fixture_data, data);
+ return data;
+};
+
+module.teardown = function(testcase) {
+ var fixtures = testcase._lp_fixture_setups;
+ var data = Y.QueryString.stringify(
+ {action: 'teardown',
+ fixtures: fixtures.join(','),
+ data: Y.JSON.stringify(testcase._lp_fixture_data)
+ });
+ var config = {
+ method: "POST",
+ data: data,
+ sync: true
+ };
+ var response = Y.io(window.location, config);
+ if (response.status !== 200) {
+ Y.error(response.responseText);
+ }
+ delete testcase._lp_fixture_setups;
+ delete testcase._lp_fixture_data;
+};
+
+ }, "0.1", {"requires": ["io", "json", "querystring"]});
=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py 2011-09-05 15:42:27 +0000
+++ lib/lp/testing/__init__.py 2011-09-21 01:29:24 +0000
@@ -8,6 +8,7 @@
__metaclass__ = type
__all__ = [
+ 'AbstractYUITestCase',
'ANONYMOUS',
'anonymous_logged_in',
'api_url',
@@ -863,41 +864,42 @@
"([#!$%&()+,./:;?@~|^{}\\[\\]`*\\\'\\\"])", r"\\\\\1", expression)
-class YUIUnitTestCase(TestCase):
+class AbstractYUITestCase(TestCase):
layer = None
suite_name = ''
js_timeout = 30000
+ html_uri = None
+ test_path = None
TIMEOUT = object()
MISSING_REPORT = object()
_yui_results = None
- def __init__(self):
+ def __init__(self, methodName=None):
"""Create a new test case without a choice of test method name.
Preventing the choice of test method ensures that we can safely
provide a test ID based on the file path.
"""
- super(YUIUnitTestCase, self).__init__("checkResults")
-
- def initialize(self, test_path):
- self.test_path = test_path
+ if methodName is None:
+ methodName = self._testMethodName
+ else:
+ assert methodName == self._testMethodName
+ super(AbstractYUITestCase, self).__init__(methodName)
def id(self):
"""Return an ID for this test based on the file path."""
return os.path.relpath(self.test_path, config.root)
def setUp(self):
- super(YUIUnitTestCase, self).setUp()
+ super(AbstractYUITestCase, self).setUp()
# html5browser imports from the gir/pygtk stack which causes
# twisted tests to break because of gtk's initialize.
import html5browser
client = html5browser.Browser()
- html_uri = 'file://%s' % os.path.join(
- config.root, 'lib', self.test_path)
- page = client.load_page(html_uri, timeout=self.js_timeout)
+ page = client.load_page(self.html_uri, timeout=self.js_timeout)
if page.return_code == page.CODE_FAIL:
self._yui_results = self.TIMEOUT
return
@@ -944,6 +946,17 @@
self.assertEqual([], failures, '\n'.join(failures))
+class YUIUnitTestCase(AbstractYUITestCase):
+
+ _testMethodName = 'checkResults'
+
+ def initialize(self, test_path):
+ # The path is a .html file.
+ self.test_path = test_path
+ self.html_uri = 'file://%s' % os.path.join(
+ config.root, 'lib', self.test_path)
+
+
def build_yui_unittest_suite(app_testing_path, yui_test_class):
suite = unittest.TestSuite()
testing_path = os.path.join(config.root, 'lib', app_testing_path)
=== modified file 'lib/lp/testing/_login.py'
--- lib/lp/testing/_login.py 2011-05-27 21:12:25 +0000
+++ lib/lp/testing/_login.py 2011-09-21 01:29:24 +0000
@@ -25,15 +25,17 @@
from contextlib import contextmanager
from zope.component import getUtility
-from zope.security.management import endInteraction
+from zope.security.management import (
+ endInteraction,
+ queryInteraction,
+ thread_local as zope_security_thread_local,
+ )
from canonical.launchpad.webapp.interaction import (
ANONYMOUS,
- get_current_principal,
setupInteractionByEmail,
setupInteractionForPerson,
)
-from canonical.launchpad.webapp.interfaces import ILaunchBag
from canonical.launchpad.webapp.servers import LaunchpadTestRequest
from canonical.launchpad.webapp.vhosts import allvhosts
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -149,16 +151,19 @@
def _with_login(login_method, identifier):
"""Make a context manager that runs with a particular log in."""
- current_person = getUtility(ILaunchBag).user
- current_principal = get_current_principal()
+ interaction = queryInteraction()
login_method(identifier)
try:
yield
finally:
- if current_principal is None:
+ if interaction is None:
logout()
else:
- login_person(current_person)
+ # This reaches under the covers of the zope.security.management
+ # module's interface in order to provide true nestable
+ # interactions. This means that real requests can be maintained
+ # across these calls, such as is desired for yuixhr fixtures.
+ zope_security_thread_local.interaction = interaction
@contextmanager
=== modified file 'lib/lp/testing/tests/test_login.py'
--- lib/lp/testing/tests/test_login.py 2011-08-12 11:37:08 +0000
+++ lib/lp/testing/tests/test_login.py 2011-09-21 01:29:24 +0000
@@ -7,6 +7,7 @@
from zope.app.security.interfaces import IUnauthenticatedPrincipal
from zope.component import getUtility
+from zope.security.management import getInteraction
from canonical.launchpad.webapp.interaction import get_current_principal
from canonical.launchpad.webapp.interfaces import IOpenLaunchBag
@@ -213,6 +214,18 @@
self.assertLoggedIn(b)
self.assertLoggedIn(a)
+ def test_person_logged_in_restores_participation(self):
+ # Once outside of the person_logged_in context, the original
+ # participation (e.g., request) is used. This can be important for
+ # yuixhr test fixtures, in particular.
+ a = self.factory.makePerson()
+ login_as(a)
+ participation = getInteraction().participations[0]
+ b = self.factory.makePerson()
+ with person_logged_in(b):
+ self.assertLoggedIn(b)
+ self.assertIs(participation, getInteraction().participations[0])
+
def test_person_logged_in_restores_logged_out(self):
# If we are logged out before the person_logged_in context, then we
# are logged out afterwards.
@@ -230,7 +243,7 @@
b = self.factory.makePerson()
try:
with person_logged_in(b):
- 1/0
+ 1 / 0
except ZeroDivisionError:
pass
self.assertLoggedIn(a)
=== added file 'lib/lp/testing/tests/test_standard_yuixhr_test_template.js'
--- lib/lp/testing/tests/test_standard_yuixhr_test_template.js 1970-01-01 00:00:00 +0000
+++ lib/lp/testing/tests/test_standard_yuixhr_test_template.js 2011-09-21 01:29:24 +0000
@@ -0,0 +1,64 @@
+YUI({
+ base: '/+icing/yui/',
+ filter: 'raw', combine: false, fetchCSS: false
+// TODO: Add other modules you want to test into the "use" list.
+}).use('test', 'console', 'json', 'cookie', 'lp.testing.serverfixture',
+ function(Y) {
+
+// This is one-half of an example yuixhr test. The other half of a
+// test like this is a file of the same name but with a .py
+// extension. It holds the fixtures that this file uses for
+// application setup and teardown. It also helps the Launchpad
+// testrunner know how to run these tests. The actual tests are
+// written here, in Javascript.
+
+// These tests are expensive to run. Keep them to a minimum,
+// preferring pure JS unit tests and pure Python unit tests.
+
+// TODO: Change this string to match what you are doing.
+var suite = new Y.Test.Suite("lp.testing.yuixhr Tests");
+var serverfixture = Y.lp.testing.serverfixture;
+
+
+// TODO: change this explanation string.
+/**
+ * Test important things...
+ */
+suite.add(new Y.Test.Case({
+ // TODO: change this name.
+ name: 'Example tests',
+
+ tearDown: function() {
+ // Always do this.
+ serverfixture.teardown(this);
+ },
+
+ // Your tests go here.
+ test_example: function() {
+ // In this example, we care about the return value of the setup.
+ // Sometimes, you won't.
+ var data = serverfixture.setup(this, 'example');
+ // Now presumably you would test something, maybe like this.
+ var response = Y.io(
+ data.product.self_link,
+ {sync: true}
+ );
+ Y.Assert.areEqual(200, response.status);
+ }
+}));
+
+// The remaining lines are necessary boilerplate. Include them.
+
+var handle_complete = function(data) {
+ window.status = '::::' + Y.JSON.stringify(data);
+ };
+Y.Test.Runner.on('complete', handle_complete);
+Y.Test.Runner.add(suite);
+
+var console = new Y.Console({newestOnTop: false});
+
+Y.on('domready', function() {
+ console.render('#log');
+ Y.Test.Runner.run();
+});
+});
=== added file 'lib/lp/testing/tests/test_standard_yuixhr_test_template.py'
--- lib/lp/testing/tests/test_standard_yuixhr_test_template.py 1970-01-01 00:00:00 +0000
+++ lib/lp/testing/tests/test_standard_yuixhr_test_template.py 2011-09-21 01:29:24 +0000
@@ -0,0 +1,154 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""{Describe your test suite here}.
+"""
+
+__metaclass__ = type
+__all__ = []
+
+from lp.testing import person_logged_in
+from lp.testing.yuixhr import (
+ login_as_person,
+ make_suite,
+ setup,
+ )
+from lp.testing.factory import LaunchpadObjectFactory
+
+# This is one half of a YUI app test. The other half is a .js test of
+# exactly the same name as your Python file, just with different file
+# extensions.
+
+# This file holds fixtures that your Javascript tests call to prepare
+# for tests. It also holds two lines of boiler plate at the bottom of
+# the file that let the test runner know how to run these tests.
+
+# You can run these yui app tests interactively. This can help with
+# construction and debugging. To do so, start Launchpad with "make
+# run-testapp" and go to
+# http://launchpad.dev:8085/+yuitest/PATH/TO/THIS/FILE/WITHOUT/EXTENSION
+# . For example,
+# http://launchpad.dev:8085/+yuitest/lp/testing/tests/test_yuixhr_fixture
+# will run the tests in
+# {launchpad}/lib/lp/testing/tests/test_yui_fixture[.py|.js].
+
+# Put your Python test fixtures here, just like these examples below.
+
+
+@setup
+def example(request, data):
+ # This is a test fixture. You will want to write your own, and
+ # delete this one. See the parallel
+ # standard_yuixhr_test_template.js to see how to call fixtures
+ # from your Javascript tests.
+ #
+ # A test fixture prepares the application for your test. You can
+ # do whatever you need here, including creating objects with an
+ # object factory and logging the browser in as a given user.
+ # You'll see an example below.
+ #
+ # Test fixtures can also return information back to your test.
+ # Simply stuff the information you want into the "data" dict. It
+ # will be converted into JSON and sent back to the Javascript
+ # caller. Even Launchpad objects are converted, using the
+ # standard lazr.restful mechanism. This can be useful in several
+ # ways. Here are three examples.
+ #
+ # First, you can communicate information about the objects you
+ # have created in the setup so that the Javascript knows what URLs
+ # to use. The code in this function has an example of this,
+ # below.
+ #
+ # Second, you can return information about verifying some aspect
+ # of the database state, so your Javascript test can easily assert
+ # some fact that is not usually easily exposed to it.
+ #
+ # Finally, you can stash information that your teardown might
+ # need. You shouldn't usually need to clean anything up, because
+ # the database and librarian are reset after every test, but if
+ # you do set something up that needs an explicit teardown, you can
+ # stash JSON-serializable information in "data" that the teardown
+ # can use to know what to clean up.
+ #
+ # You can compose these setups and teardowns as well, using .extend.
+ # There is a small example of this as well, below.
+ #
+ # As a full example, we will create an administrator and another
+ # person; we will have the administrator create an object; we will
+ # log the browser in as the other person; and we will stash
+ # information about the object and the two people in the data
+ # object.
+ #
+ # Again, this is a semi-random example. Rip this whole fixture
+ # out, and write the ones that you need.
+ factory = LaunchpadObjectFactory()
+ data['admin'] = factory.makeAdministrator()
+ data['user'] = factory.makePerson()
+ with person_logged_in(data['admin']):
+ data['product'] = factory.makeProduct(owner=data['admin'])
+ # This logs the browser in as a given person. You need to use
+ # this function for that purpose--the standard lp.testing login
+ # functions are insufficient.
+ login_as_person(data['user'])
+ # Now we've done everything we said we would. Let's imagine that
+ # we had to also write some file to disk that would need to be
+ # cleaned up at the end of the test. We might stash information
+ # about that in "data" too.
+ data['some random data we might need for cleaning up'] = 'rutebega'
+ # Now we are done. We don't need to return anything, because we
+ # have been mutating the "data" dict that was passed in. (This
+ # will become slightly more important if you ever want to use
+ # .extend.)
+@example.add_cleanup
+def example(request, data):
+ # This is an example of a cleanup function, which will be called
+ # at the end of the test. You usually won't need one of these,
+ # because the database and librarian are automatically reset after
+ # every test. If you don't need it, don't write it!
+ #
+ # A cleanup function gets the data from the setup, after it has
+ # been converted into JSON and back again. So, in this case, we
+ # could look at the clean up data we stashed in our setup if we
+ # wanted to, and do something with it. We don't really need to do
+ # anything with it, so we'll just show that the data is still
+ # around, and then stop.
+ assert (
+ data['some random data we might need for cleaning up'] == 'rutebega')
+
+# Sometimes you might have setup and teardown code that can be shared
+# within your test suite as part of several larger jobs. You can use
+# "extend" for that if you like.
+
+
+@example.extend
+def extended_example(request, data):
+ # We have declared a new fixture named "extended_example", but
+ # instead of using "setup" we used the "extend" method of the
+ # "example" fixture. You can think of "example" wrapping
+ # "extended_example". During test setup, the "example" setup will
+ # be called first. Then this function, in "extended_example,"
+ # will be called. During test teardown, the "extended_example"
+ # cleanups will be called first, followed by the "example"
+ # cleanups.
+ #
+ # The "data" dict is the same one that was passed to the wrapping
+ # fixture. You can look at it, mutate it, or do what you need.
+ # You are also responsible for not overwriting or mangling the
+ # dict so that the wrapping fixtures data and/or teardown is
+ # compromised.
+ #
+ # For this example, we will log in as the user and make something.
+ factory = LaunchpadObjectFactory()
+ with person_logged_in(data['user']):
+ data['another_product'] = factory.makeProduct(owner=data['user'])
+
+# That's the end of the example fixtures.
+
+# IMPORTANT!! These last two lines are boilerplate that let
+# Launchpad's testrunner find the associated Javascript tests and run
+# them. You should not have to change them, but you do need to have
+# them. Feel free to delete these comments, though. :-)
+
+
+def test_suite():
+ return make_suite(__name__)
=== added file 'lib/lp/testing/tests/test_yuixhr.py'
--- lib/lp/testing/tests/test_yuixhr.py 1970-01-01 00:00:00 +0000
+++ lib/lp/testing/tests/test_yuixhr.py 2011-09-21 01:29:24 +0000
@@ -0,0 +1,391 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the lp.testing.yuixhr."""
+
+__metaclass__ = type
+
+
+import re
+import os
+from shutil import rmtree
+import simplejson
+import sys
+import tempfile
+import types
+
+from storm.exceptions import DisconnectionError
+from testtools.testcase import ExpectedException
+import transaction
+from zope.component import getUtility
+from zope.interface.verify import verifyObject
+from zope.publisher.interfaces import NotFound
+from zope.publisher.interfaces.browser import IBrowserPublisher
+from zope.publisher.interfaces.http import IResult
+from zope.security.proxy import removeSecurityProxy
+
+from canonical.config import config
+from canonical.launchpad.webapp.interfaces import ILaunchpadRoot
+from canonical.testing.layers import LaunchpadFunctionalLayer
+
+from lp.registry.interfaces.product import IProductSet
+from lp.testing import (
+ TestCase,
+ login,
+ ANONYMOUS,
+ )
+from lp.testing.views import create_view
+from lp.testing.yuixhr import setup
+from lp.testing.tests import test_yuixhr_fixture
+from lp.testing.publication import test_traverse
+
+TEST_MODULE_NAME = '_lp_.tests'
+
+
+def create_traversed_view(*args, **kwargs):
+ login(ANONYMOUS)
+ root = getUtility(ILaunchpadRoot)
+ view = create_view(root, '+yuitest', *args, **kwargs)
+ view.names = kwargs['path_info'].split('/')[2:]
+ return view
+
+
+class TestYUITestFixtureController(TestCase):
+ layer = LaunchpadFunctionalLayer
+
+ def test_provides_browserpublisher(self):
+ root = getUtility(ILaunchpadRoot)
+ view = create_view(root, '+yuitest')
+ self.assertTrue(view, verifyObject(IBrowserPublisher, view))
+
+ def test_traverse_stores_the_path(self):
+ login(ANONYMOUS)
+ object, view, request = test_traverse(
+ 'http://launchpad.dev/+yuitest/'
+ 'lib/lp/testing/tests/test_yuixhr_fixture.js')
+ self.assertEquals(
+ 'lib/lp/testing/tests/test_yuixhr_fixture.js',
+ removeSecurityProxy(view).traversed_path)
+
+ def test_request_is_js(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/test_yuixhr_fixture.js')
+ view.initialize()
+ self.assertEquals(view.JAVASCRIPT, view.action)
+
+ def test_request_is_html(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/test_yuixhr_fixture')
+ view.initialize()
+ self.assertEquals(view.HTML, view.action)
+
+ def test_request_is_setup(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'base_line'},
+ method='POST')
+ view.initialize()
+ self.assertEquals(view.SETUP, view.action)
+ self.assertEquals(['base_line'], view.fixtures)
+
+ def test_request_is_teardown(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/test_yuixhr_fixture',
+ form={'action': 'teardown', 'fixtures': 'base_line'},
+ method='POST')
+ view.initialize()
+ self.assertEquals(view.TEARDOWN, view.action)
+ self.assertEquals(['base_line'], view.fixtures)
+
+ def test_page(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/test_yuixhr_fixture')
+ view.initialize()
+ content = view.page()
+ self.assertTrue(content.startswith('<!DOCTYPE HTML'))
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ re.escape(
+ 'src="/+yuitest/lp/testing/tests/test_yuixhr_fixture.js"'),
+ content)
+
+ def test_render_javascript(self):
+ top_dir = tempfile.mkdtemp()
+ js_dir = os.path.join(top_dir, 'lib')
+ os.mkdir(js_dir)
+ true_root = config.root
+ self.addCleanup(setattr, config, 'root', true_root)
+ self.addCleanup(rmtree, top_dir)
+ open(os.path.join(js_dir, 'foo.py'), 'w').close()
+ js_file = open(os.path.join(js_dir, 'foo.js'), 'w')
+ js_file.write('// javascript')
+ js_file.close()
+ config.root = top_dir
+ view = create_traversed_view(path_info='/+yuitest/foo.js')
+ content = view()
+ self.assertEqual('// javascript', content.read())
+ self.assertEqual(
+ 'text/javascript',
+ view.request.response.getHeader('Content-Type'))
+
+ def test_javascript_must_have_a_py_fixture(self):
+ js_dir = tempfile.mkdtemp()
+ true_root = config.root
+ self.addCleanup(setattr, config, 'root', true_root)
+ self.addCleanup(rmtree, js_dir)
+ open(os.path.join(js_dir, 'foo.js'), 'w').close()
+ config.root = js_dir
+ view = create_traversed_view(path_info='/+yuitest/foo.js')
+ with ExpectedException(NotFound, '.*'):
+ view()
+
+ def test_missing_javascript_raises_NotFound(self):
+ js_dir = tempfile.mkdtemp()
+ true_root = config.root
+ self.addCleanup(setattr, config, 'root', true_root)
+ self.addCleanup(rmtree, js_dir)
+ open(os.path.join(js_dir, 'foo.py'), 'w').close()
+ config.root = js_dir
+ view = create_traversed_view(path_info='/+yuitest/foo')
+ with ExpectedException(NotFound, '.*'):
+ view()
+
+ def test_render_html(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/test_yuixhr_fixture')
+ content = view()
+ self.assertTrue(content.startswith('<!DOCTYPE HTML'))
+ self.assertEqual(
+ 'text/html',
+ view.request.response.getHeader('Content-Type'))
+
+ def test_get_fixtures(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'baseline'},
+ method='POST')
+ view.initialize()
+ self.assertEquals(
+ test_yuixhr_fixture._fixtures_, view.get_fixtures())
+
+ def make_example_setup_function_module(self):
+ module = types.ModuleType(TEST_MODULE_NAME)
+ sys.modules[TEST_MODULE_NAME] = module
+ self.addCleanup(lambda: sys.modules.pop(TEST_MODULE_NAME))
+
+ def baseline(request, data):
+ data['hi'] = 'world'
+ data['called'] = ['baseline']
+ baseline.__module__ = TEST_MODULE_NAME
+ module.baseline = baseline
+ return module
+
+ def test_setup_decorator(self):
+ module = self.make_example_setup_function_module()
+ fixture = setup(module.baseline)
+ self.assertTrue('_fixtures_' in module.__dict__)
+ self.assertTrue('baseline' in module._fixtures_)
+ self.assertEquals(fixture, module._fixtures_['baseline'])
+ self.assertTrue(getattr(fixture, 'add_cleanup', None) is not None)
+ self.assertTrue(getattr(fixture, 'teardown', None) is not None)
+ self.assertTrue(getattr(fixture, 'extend', None) is not None)
+
+ def test_do_setup(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'baseline'},
+ method='POST')
+ content = view()
+ self.assertEqual({'hello': 'world'}, simplejson.loads(content))
+ self.assertEqual(
+ 'application/json',
+ view.request.response.getHeader('Content-Type'))
+
+ def test_do_setup_data_returns_object_summaries(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'make_product'},
+ method='POST')
+ data = simplejson.loads(view())
+ # The licenses is just an example.
+ self.assertEqual(['GNU GPL v2'], data['product']['licenses'])
+
+ def test_do_setup_data_object_summaries_are_redacted_if_necessary(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'make_product'},
+ method='POST')
+ data = simplejson.loads(view())
+ self.assertEqual(
+ 'tag:launchpad.net:2008:redacted',
+ data['product']['project_reviewed'])
+
+ def test_do_setup_unproxied_data_object_summaries_are_redacted(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'naughty_make_product'},
+ method='POST')
+ data = simplejson.loads(view())
+ self.assertEqual(
+ 'tag:launchpad.net:2008:redacted',
+ data['product']['project_reviewed'])
+
+ def test_do_setup_data_object_summaries_not_redacted_if_possible(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'make_product_loggedin'},
+ method='POST')
+ data = simplejson.loads(view())
+ self.assertEqual(
+ False,
+ data['product']['project_reviewed'])
+
+ def test_add_cleanup_decorator(self):
+ fixture = setup(self.make_example_setup_function_module().baseline)
+ result = []
+
+ def my_teardown(request, data):
+ result.append('foo')
+ self.assertEquals(fixture, fixture.add_cleanup(my_teardown))
+ fixture.teardown(None, None)
+ self.assertEquals(['foo'], result)
+
+ def test_add_cleanup_decorator_twice(self):
+ fixture = setup(self.make_example_setup_function_module().baseline)
+ result = []
+
+ def my_teardown(request, data):
+ result.append('foo')
+
+ def my_other_teardown(request, data):
+ result.append('bar')
+ self.assertEquals(fixture, fixture.add_cleanup(my_teardown))
+ self.assertEquals(fixture, fixture.add_cleanup(my_other_teardown))
+ fixture.teardown(None, None)
+ self.assertEquals(['bar', 'foo'], result)
+
+ def test_do_teardown(self):
+ del test_yuixhr_fixture._received[:]
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'teardown', 'fixtures': 'baseline',
+ 'data': simplejson.dumps({'bonjour': 'monde'})},
+ method='POST')
+ view.request.response.setResult(view())
+ # The teardowns are called *before* the result is iterated.
+ self.assertEqual(1, len(test_yuixhr_fixture._received))
+ self.assertEqual(
+ ('baseline', view.request, {'bonjour': 'monde'}),
+ test_yuixhr_fixture._received[0])
+ result = view.request.response.consumeBodyIter()
+ self.assertProvides(result, IResult)
+ self.assertEqual('\n', ''.join(result))
+ self.assertEqual(
+ '1',
+ view.request.response.getHeader('Content-Length'))
+ del test_yuixhr_fixture._received[:] # Cleanup
+
+ def test_do_teardown_resets_database_only_after_request_completes(self):
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'setup', 'fixtures': 'make_product'},
+ method='POST')
+ data = view()
+ # Committing the transaction makes sure that we are not just seeing
+ # the effect of an abort, below.
+ transaction.commit()
+ name = simplejson.loads(data)['product']['name']
+ products = getUtility(IProductSet)
+ # The new product exists after the setup.
+ self.assertFalse(products.getByName(name) is None)
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'teardown', 'fixtures': 'make_product',
+ 'data': data},
+ method='POST')
+ view.request.response.setResult(view())
+ # The product still exists after the teardown has been called.
+ self.assertFalse(products.getByName(name) is None)
+ # Iterating over the result causes the database to be reset.
+ ''.join(view.request.response.consumeBodyIter())
+ # The database is disconnected now.
+ self.assertRaises(
+ DisconnectionError,
+ products.getByName, name)
+ # If we abort the transaction...
+ transaction.abort()
+ # ...we see that the product is gone: the database has been reset.
+ self.assertTrue(products.getByName(name) is None)
+
+ def test_do_teardown_multiple(self):
+ # Teardown should call fixtures in reverse order.
+ del test_yuixhr_fixture._received[:]
+ view = create_traversed_view(
+ path_info='/+yuitest/lp/testing/tests/'
+ 'test_yuixhr_fixture',
+ form={'action': 'teardown', 'fixtures': 'baseline,second',
+ 'data': simplejson.dumps({'bonjour': 'monde'})},
+ method='POST')
+ view()
+ self.assertEqual(
+ 'second', test_yuixhr_fixture._received[0][0])
+ self.assertEqual(
+ 'baseline', test_yuixhr_fixture._received[1][0])
+ del test_yuixhr_fixture._received[:]
+
+ def test_extend_decorator_setup(self):
+ module = self.make_example_setup_function_module()
+ original_fixture = setup(module.baseline)
+ second_fixture = self.make_extend_fixture(
+ original_fixture, 'second')
+ data = {}
+ second_fixture(None, data)
+ self.assertEqual(['baseline', 'second'], data['called'])
+ data = {}
+ original_fixture(None, data)
+ self.assertEqual(['baseline'], data['called'])
+
+ def test_extend_decorator_can_be_composed(self):
+ module = self.make_example_setup_function_module()
+ original_fixture = setup(module.baseline)
+ second_fixture = self.make_extend_fixture(
+ original_fixture, 'second')
+ third_fixture = self.make_extend_fixture(
+ second_fixture, 'third')
+ data = {}
+ third_fixture(None, data)
+ self.assertEqual(['baseline', 'second', 'third'], data['called'])
+
+ def make_extend_fixture(self, original_fixture, name):
+ f = lambda request, data: data['called'].append(name)
+ f.__module__ == TEST_MODULE_NAME
+ return original_fixture.extend(f)
+
+ def test_extend_calls_teardown_in_reverse_order(self):
+ module = self.make_example_setup_function_module()
+ original_fixture = setup(module.baseline)
+ second_fixture = self.make_extend_fixture(
+ original_fixture, 'second')
+ third_fixture = self.make_extend_fixture(
+ second_fixture, 'third')
+ called = []
+ original_fixture.add_cleanup(
+ lambda request, data: called.append('original'))
+ second_fixture.add_cleanup(
+ lambda request, data: called.append('second'))
+ third_fixture.add_cleanup(
+ lambda request, data: called.append('third'))
+ third_fixture.teardown(None, dict())
+ self.assertEquals(['third', 'second', 'original'], called)
+
+ del called[:]
+ original_fixture.teardown(None, dict())
+ self.assertEquals(['original'], called)
=== added file 'lib/lp/testing/tests/test_yuixhr_fixture.js'
--- lib/lp/testing/tests/test_yuixhr_fixture.js 1970-01-01 00:00:00 +0000
+++ lib/lp/testing/tests/test_yuixhr_fixture.js 2011-09-21 01:29:24 +0000
@@ -0,0 +1,175 @@
+YUI({
+ base: '/+icing/yui/',
+ filter: 'raw', combine: false, fetchCSS: false
+}).use('test', 'console', 'json', 'cookie', 'lp.testing.serverfixture',
+ function(Y) {
+
+var suite = new Y.Test.Suite("lp.testing.yuixhr Tests");
+var module = Y.lp.testing.serverfixture;
+
+
+/**
+ * Test setup and teardown of yuixhr fixtures, and the asociated JS module.
+ */
+suite.add(new Y.Test.Case({
+ name: 'Fixture setup and teardown tests',
+
+ tearDown: function() {
+ delete this._lp_fixture_setups;
+ delete this._lp_fixture_data;
+ },
+
+ _should: {
+ error: {
+ test_bad_http_call_raises_error: true,
+ test_bad_http_teardown_raises_error: true
+ }
+ },
+
+ test_simple_setup: function() {
+ var data = module.setup(this, 'baseline');
+ Y.ArrayAssert.itemsAreEqual(['baseline'], this._lp_fixture_setups);
+ Y.ObjectAssert.areEqual({'hello': 'world'}, data);
+ Y.ObjectAssert.areEqual({'hello': 'world'}, this._lp_fixture_data);
+ module.teardown(this); // Just for cleanliness, not for testing.
+ },
+
+ test_setup_with_multiple_fixtures: function() {
+ var data = module.setup(this, 'baseline', 'second');
+ Y.ArrayAssert.itemsAreEqual(
+ ['baseline', 'second'], this._lp_fixture_setups);
+ Y.ObjectAssert.areEqual({'hello': 'world', 'second': 'here'}, data);
+ Y.ObjectAssert.areEqual(
+ {'hello': 'world', 'second': 'here'}, this._lp_fixture_data);
+ module.teardown(this); // Just for cleanliness, not for testing.
+ },
+
+ test_multiple_setup_calls: function() {
+ var data = module.setup(this, 'baseline');
+ var second_data = module.setup(this, 'second');
+ Y.ArrayAssert.itemsAreEqual(
+ ['baseline', 'second'], this._lp_fixture_setups);
+ Y.ObjectAssert.areEqual({'hello': 'world'}, data);
+ Y.ObjectAssert.areEqual({'second': 'here'}, second_data);
+ Y.ObjectAssert.areEqual(
+ {'hello': 'world', 'second': 'here'}, this._lp_fixture_data);
+ module.teardown(this); // Just for cleanliness, not for testing.
+ },
+
+ test_teardown_clears_attributes: function() {
+ var data = module.setup(this, 'baseline');
+ module.teardown(this);
+ Y.Assert.isUndefined(this._lp_fixture_setups);
+ Y.Assert.isUndefined(this._lp_fixture_data);
+ },
+
+ test_bad_http_call_raises_error: function() {
+ module.setup(this, 'does not exist');
+ },
+
+ test_bad_http_call_shows_traceback: function() {
+ try {module.setup(this, 'does not exist');}
+ catch (err) {
+ Y.Assert.areEqual('Traceback (most recent call last)',
+ err.message.substring(0, 33));
+ }
+ },
+
+ test_bad_http_teardown_raises_error: function() {
+ module.setup(this, 'teardown_will_fail');
+ module.teardown(this);
+ },
+
+ test_bad_http_teardown_shows_traceback: function() {
+ module.setup(this, 'teardown_will_fail');
+ try {module.teardown(this);}
+ catch (err) {
+ Y.Assert.areEqual('Traceback (most recent call last)',
+ err.message.substring(0, 33));
+ }
+ },
+
+ test_setup_called_twice_with_same_fixture: function() {
+ // This is arguably not desirable, but it is the way it works now.
+ var data = module.setup(this, 'baseline');
+ var second_data = module.setup(this, 'baseline');
+ Y.ArrayAssert.itemsAreEqual(
+ ['baseline', 'baseline'], this._lp_fixture_setups);
+ Y.ObjectAssert.areEqual({'hello': 'world'}, data);
+ Y.ObjectAssert.areEqual({'hello': 'world'}, second_data);
+ Y.ObjectAssert.areEqual(
+ {'hello': 'world'}, this._lp_fixture_data);
+ module.teardown(this); // Just for cleanliness, not for testing.
+ },
+
+ test_teardown: function() {
+ module.setup(this, 'faux_database_thing');
+ module.teardown(this);
+ var data = module.setup(this, 'faux_database_thing');
+ Y.ObjectAssert.areEqual(
+ {'previous_value': 'teardown was called'}, data);
+ },
+
+ test_teardown_receives_data_from_setup: function() {
+ module.setup(this, 'show_teardown_value');
+ module.teardown(this);
+ var data = module.setup(this, 'faux_database_thing');
+ Y.ObjectAssert.areEqual(
+ {'setup_data': 'Hello world'}, data.previous_value);
+ },
+
+ test_teardown_resets_database: function() {
+ var data = module.setup(this, 'make_product');
+ var response = Y.io(
+ data.product.self_link,
+ {sync: true}
+ );
+ Y.Assert.areEqual(200, response.status);
+ module.teardown(this);
+ response = Y.io(
+ data.product.self_link,
+ {sync: true}
+ );
+ Y.Assert.areEqual(404, response.status);
+ },
+
+ test_login_works: function() {
+ // Make sure the session cookie is cleared out at start of test.
+ Y.Cookie.remove('launchpad_tests');
+ // Make a product
+ var data = module.setup(this, 'make_product');
+ // We can't see this because only Launchpad and Registry admins can.
+ Y.Assert.areEqual(
+ 'tag:launchpad.net:2008:redacted', data.product.project_reviewed);
+ // Login as a Launchpad admin.
+ module.setup(this, 'login_as_admin');
+ // The session cookie is set.
+ Y.Assert.isTrue(Y.Cookie.exists('launchpad_tests'));
+ // We can now see things that only a Launchpad admin can see.
+ var response = Y.io(
+ data.product.self_link,
+ {sync: true, headers: {Accept: 'application/json'}}
+ );
+ var result = Y.JSON.parse(response.responseText);
+ Y.Assert.areEqual(false, result.project_reviewed);
+ module.teardown(this);
+ // After teardown, the database is cleared out, as shown in other
+ // tests, so we are not logged in practically--the user is gone--but
+ // also our session cookie is gone.
+ Y.Assert.isFalse(Y.Cookie.exists('launchpad_tests'));
+ }
+}));
+
+var handle_complete = function(data) {
+ window.status = '::::' + Y.JSON.stringify(data);
+ };
+Y.Test.Runner.on('complete', handle_complete);
+Y.Test.Runner.add(suite);
+
+var console = new Y.Console({newestOnTop: false});
+
+Y.on('domready', function() {
+ console.render('#log');
+ Y.Test.Runner.run();
+});
+});
=== added file 'lib/lp/testing/tests/test_yuixhr_fixture.py'
--- lib/lp/testing/tests/test_yuixhr_fixture.py 1970-01-01 00:00:00 +0000
+++ lib/lp/testing/tests/test_yuixhr_fixture.py 2011-09-21 01:29:24 +0000
@@ -0,0 +1,102 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""These are yui appserver fixtures for the yui appserver test code's tests.
+"""
+
+__metaclass__ = type
+__all__ = []
+
+from zope.security.proxy import removeSecurityProxy
+
+from lp.testing import login_person
+from lp.testing.yuixhr import (
+ login_as_person,
+ make_suite,
+ setup,
+ )
+from lp.testing.factory import LaunchpadObjectFactory
+
+# The following are the fixtures needed by the tests.
+
+# We use this variable for test results.
+_received = []
+
+
+@setup
+def baseline(request, data):
+ data['hello'] = 'world'
+
+
+@baseline.add_cleanup
+def baseline(request, data):
+ global _received
+ _received.append(('baseline', request, data))
+
+
+@setup
+def second(request, data):
+ data['second'] = 'here'
+@second.add_cleanup
+def second(request, data):
+ global _received
+ _received.append(('second', request, data))
+
+test_value = None
+
+
+@setup
+def faux_database_thing(request, data):
+ global test_value
+ data['previous_value'] = test_value
+ test_value = None
+@faux_database_thing.add_cleanup
+def faux_database_thing(request, data):
+ global test_value
+ test_value = 'teardown was called'
+
+
+@setup
+def show_teardown_value(request, data):
+ data['setup_data'] = 'Hello world'
+@show_teardown_value.add_cleanup
+def show_teardown_value(request, data):
+ global test_value
+ test_value = data
+
+factory = LaunchpadObjectFactory()
+
+
+@setup
+def make_product(request, data):
+ data['product'] = factory.makeProduct()
+
+
+@setup
+def make_product_loggedin(request, data):
+ data['person'] = factory.makeAdministrator()
+ login_person(data['person'])
+ data['product'] = factory.makeProduct(owner=data['person'])
+
+
+@setup
+def naughty_make_product(request, data):
+ data['product'] = removeSecurityProxy(factory.makeProduct())
+
+
+@setup
+def teardown_will_fail(request, data):
+ pass
+@teardown_will_fail.add_cleanup
+def teardown_will_fail(request, data):
+ raise RuntimeError('rutebegas')
+
+
+@setup
+def login_as_admin(request, data):
+ data['user'] = factory.makeAdministrator()
+ login_as_person(data['user'])
+
+
+def test_suite():
+ return make_suite(__name__)
=== modified file 'lib/lp/testing/views.py'
--- lib/lp/testing/views.py 2011-05-21 18:41:20 +0000
+++ lib/lp/testing/views.py 2011-09-21 01:29:24 +0000
@@ -7,11 +7,8 @@
__all__ = [
'create_view',
'create_initialized_view',
- 'YUITestFileView',
]
-import os
-
from zope.component import (
getMultiAdapter,
getUtility,
@@ -21,7 +18,6 @@
newInteraction,
)
-from canonical.config import config
from canonical.launchpad.layers import setFirstLayer
from canonical.launchpad.webapp.servers import WebServiceTestRequest
from canonical.launchpad.webapp.interfaces import (
@@ -30,7 +26,6 @@
)
from canonical.launchpad.webapp.publisher import layer_for_rootsite
from canonical.launchpad.webapp.servers import LaunchpadTestRequest
-from canonical.lazr import ExportedFolder
def create_view(context, name, form=None, layer=None, server_url=None,
@@ -102,13 +97,6 @@
return view
-class YUITestFileView(ExportedFolder):
- """Export the lib directory where the test assets reside."""
-
- folder = os.path.join(config.root, 'lib/')
- export_subdirectories = True
-
-
def create_webservice_error_view(error):
"""Return a view of the error with a webservice request."""
request = WebServiceTestRequest()
=== added file 'lib/lp/testing/yuixhr.py'
--- lib/lp/testing/yuixhr.py 1970-01-01 00:00:00 +0000
+++ lib/lp/testing/yuixhr.py 2011-09-21 01:29:24 +0000
@@ -0,0 +1,331 @@
+# Copyright 2011 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Fixture code for YUITest + XHR integration testing."""
+
+__metaclass__ = type
+__all__ = [
+ 'login_as_person',
+ 'make_suite',
+ 'setup',
+ 'YUITestFixtureControllerView',
+]
+
+import os
+import simplejson
+import sys
+from textwrap import dedent
+import traceback
+import unittest
+
+from lazr.restful import ResourceJSONEncoder
+from lazr.restful.utils import get_current_browser_request
+from zope.component import getUtility
+from zope.exceptions.exceptionformatter import format_exception
+from zope.interface import implements
+from zope.publisher.interfaces import NotFound
+from zope.publisher.interfaces.http import IResult
+from zope.security.checker import (
+ NamesChecker,
+ ProxyFactory)
+from zope.security.proxy import removeSecurityProxy
+from zope.session.interfaces import IClientIdManager
+
+from canonical.config import config
+from canonical.launchpad.webapp.interfaces import (
+ IPlacelessAuthUtility,
+ IOpenLaunchBag,
+ )
+from canonical.launchpad.webapp.login import logInPrincipal
+from canonical.launchpad.webapp.publisher import LaunchpadView
+from canonical.testing.layers import (
+ DatabaseLayer,
+ LaunchpadLayer,
+ LibrarianLayer,
+ LayerProcessController,
+ YUIAppServerLayer,
+ )
+from lp.app.versioninfo import revno
+from lp.testing import AbstractYUITestCase
+
+EXPLOSIVE_ERRORS = (SystemExit, MemoryError, KeyboardInterrupt)
+
+
+class setup:
+ """Decorator to mark a function as a fixture available from JavaScript.
+
+ This makes the function available to call from JS integration tests over
+ XHR. The fixture setup can have one or more cleanups tied to it with
+ ``add_cleanup`` decorator/callable and can be composed with another
+ function with the ``extend`` decorator/callable.
+ """
+ def __init__(self, function, extends=None):
+ self._cleanups = []
+ self._function = function
+ self._extends = extends
+ # We can't use locals because we want to affect the function's module,
+ # not this one.
+ module = sys.modules[function.__module__]
+ fixtures = getattr(module, '_fixtures_', None)
+ if fixtures is None:
+ fixtures = module._fixtures_ = {}
+ fixtures[function.__name__] = self
+
+ def __call__(self, request, data):
+ """Call the originally decorated setup function."""
+ if self._extends is not None:
+ self._extends(request, data)
+ self._function(request, data)
+
+ def add_cleanup(self, function):
+ """Add a cleanup function to be executed on teardown, FILO."""
+ self._cleanups.append(function)
+ return self
+
+ def teardown(self, request, data):
+ """Run all registered cleanups. If no cleanups, a no-op."""
+ for f in reversed(self._cleanups):
+ f(request, data)
+ if self._extends is not None:
+ self._extends.teardown(request, data)
+
+ def extend(self, function):
+ return setup(function, self)
+
+
+def login_as_person(person):
+ """This is a helper function designed to be used within a fixture.
+
+ Provide a person, such as one generated by LaunchpadObjectFactory, and
+ the browser will become logged in as this person.
+
+ Explicit tear-down is unnecessary because the database is reset at the end
+ of every test, and the cookie is discarded.
+ """
+ if person.is_team:
+ raise AssertionError("Please do not try to login as a team")
+ email = removeSecurityProxy(person.preferredemail).email
+ request = get_current_browser_request()
+ assert request is not None, "We do not have a browser request."
+ authutil = getUtility(IPlacelessAuthUtility)
+ principal = authutil.getPrincipalByLogin(email, want_password=False)
+ launchbag = getUtility(IOpenLaunchBag)
+ launchbag.setLogin(email)
+ logInPrincipal(request, principal, email)
+
+
+class CloseDbResult:
+ implements(IResult)
+
+ # This is machinery, not content. We specify our security checker here
+ # directly for clarity.
+ __Security_checker__ = NamesChecker(['next', '__iter__'])
+
+ def __iter__(self):
+ try:
+ # Reset the session.
+ LaunchpadLayer.resetSessionDb()
+ # Yield control to asyncore for a second, just to be a
+ # little bit nice. We could be even nicer by moving this
+ # whole teardown/setup dance to a thread and waiting for
+ # it to be done, but there's not a (known) compelling need
+ # for that right now, and doing it this way is slightly
+ # simpler.
+ yield ''
+ DatabaseLayer.testSetUp()
+ yield ''
+ # Reset the librarian.
+ LibrarianLayer.testTearDown()
+ yield ''
+ # Reset the database.
+ DatabaseLayer.testTearDown()
+ yield ''
+ LibrarianLayer.testSetUp()
+ except (SystemExit, KeyboardInterrupt):
+ raise
+ except:
+ print "Hm, serious error when trying to clean up the test."
+ traceback.print_exc()
+ # We're done, so we can yield the body.
+ yield '\n'
+
+
+class YUITestFixtureControllerView(LaunchpadView):
+ """Dynamically loads YUI test along their fixtures run over an app server.
+ """
+
+ JAVASCRIPT = 'JAVASCRIPT'
+ HTML = 'HTML'
+ SETUP = 'SETUP'
+ TEARDOWN = 'TEARDOWN'
+
+ page_template = dedent("""\
+ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+ "http://www.w3.org/TR/html4/strict.dtd">
+ <html>
+ <head>
+ <title>Test</title>
+ <script type="text/javascript"
+ src="/+icing/rev%(revno)s/build/launchpad.js"></script>
+ <link rel="stylesheet"
+ href="/+icing/yui/assets/skins/sam/skin.css"/>
+ <link rel="stylesheet" href="/+icing/rev%(revno)s/combo.css"/>
+ <style>
+ /* Taken and customized from testlogger.css */
+ .yui-console-entry-src { display:none; }
+ .yui-console-entry.yui-console-entry-pass .yui-console-entry-cat {
+ background-color: green;
+ font-weight: bold;
+ color: white;
+ }
+ .yui-console-entry.yui-console-entry-fail .yui-console-entry-cat {
+ background-color: red;
+ font-weight: bold;
+ color: white;
+ }
+ .yui-console-entry.yui-console-entry-ignore .yui-console-entry-cat {
+ background-color: #666;
+ font-weight: bold;
+ color: white;
+ }
+ </style>
+ <script type="text/javascript" src="%(test_module)s"></script>
+ </head>
+ <body class="yui3-skin-sam">
+ <div id="log"></div>
+ </body>
+ </html>
+ """)
+
+ def __init__(self, context, request):
+ super(YUITestFixtureControllerView, self).__init__(context, request)
+ self.names = []
+ self.action = None
+ self.fixtures = []
+
+ @property
+ def traversed_path(self):
+ return os.path.join(*self.names)
+
+ def initialize(self):
+ path, ext = os.path.splitext(self.traversed_path)
+ full_path = os.path.join(config.root, 'lib', path)
+ if not os.path.exists(full_path + '.py'):
+ raise NotFound(self, full_path + '.py', self.request)
+ if not os.path.exists(full_path + '.js'):
+ raise NotFound(self, full_path + '.js', self.request)
+
+ if ext == '.js':
+ self.action = self.JAVASCRIPT
+ else:
+ if self.request.method == 'GET':
+ self.action = self.HTML
+ else:
+ self.fixtures = self.request.form['fixtures'].split(',')
+ if self.request.form['action'] == 'setup':
+ self.action = self.SETUP
+ else:
+ self.action = self.TEARDOWN
+
+ # The following two zope methods publishTraverse and browserDefault
+ # allow this view class to take control of traversal from this point
+ # onwards. Traversed names just end up in self.names.
+ def publishTraverse(self, request, name):
+ """Traverse to the given name."""
+ # The two following constraints are enforced by the publisher.
+ assert os.path.sep not in name, (
+ 'traversed name contains os.path.sep: %s' % name)
+ assert name != '..', 'traversing to ..'
+ self.names.append(name)
+ return self
+
+ def browserDefault(self, request):
+ return self, ()
+
+ def page(self):
+ return self.page_template % dict(
+ test_module='/+yuitest/%s.js' % self.traversed_path,
+ revno=revno)
+
+ def get_fixtures(self):
+ module_name = '.'.join(self.names)
+ test_module = __import__(
+ module_name, globals(), locals(), ['_fixtures_'], 0)
+ return test_module._fixtures_
+
+ def render(self):
+ if self.action == self.JAVASCRIPT:
+ self.request.response.setHeader('Content-Type', 'text/javascript')
+ result = open(
+ os.path.join(config.root, 'lib', self.traversed_path))
+ elif self.action == self.HTML:
+ self.request.response.setHeader('Content-Type', 'text/html')
+ result = self.page()
+ elif self.action == self.SETUP:
+ data = {}
+ fixtures = self.get_fixtures()
+ try:
+ for fixture_name in self.fixtures:
+ __traceback_info__ = (fixture_name, data)
+ fixtures[fixture_name](self.request, data)
+ except EXPLOSIVE_ERRORS:
+ raise
+ except:
+ self.request.response.setStatus(500)
+ result = ''.join(format_exception(*sys.exc_info()))
+ else:
+ self.request.response.setHeader(
+ 'Content-Type', 'application/json')
+ # We use the ProxyFactory so that the restful
+ # redaction code is always used.
+ result = simplejson.dumps(
+ ProxyFactory(data), cls=ResourceJSONEncoder)
+ elif self.action == self.TEARDOWN:
+ data = simplejson.loads(self.request.form['data'])
+ fixtures = self.get_fixtures()
+ try:
+ for fixture_name in reversed(self.fixtures):
+ __traceback_info__ = (fixture_name, data)
+ fixtures[fixture_name].teardown(self.request, data)
+ except EXPLOSIVE_ERRORS:
+ raise
+ except:
+ self.request.response.setStatus(500)
+ result = ''.join(format_exception(*sys.exc_info()))
+ else:
+ # Remove the session cookie, in case we have one.
+ self.request.response.expireCookie(
+ getUtility(IClientIdManager).namespace)
+ # Blow up the database once we are out of this transaction
+ # by passing a result that will do so when it is iterated
+ # through in asyncore.
+ self.request.response.setHeader('Content-Length', 1)
+ result = CloseDbResult()
+ return result
+
+
+# This class cannot be imported directly into a test suite because
+# then the test loader will sniff and (try to) run it. Use make_suite
+# instead (or import this module rather than this class).
+class YUIAppServerTestCase(AbstractYUITestCase):
+ "Instantiate this test case with the Python fixture module name."
+
+ layer = YUIAppServerLayer
+ _testMethodName = 'runTest'
+
+ def __init__(self, module_name=None):
+ self.module_name = module_name
+ # This needs to be done early so the "id" is set correctly.
+ self.test_path = self.module_name.replace('.', '/')
+ super(YUIAppServerTestCase, self).__init__()
+
+ def setUp(self):
+ root_url = LayerProcessController.appserver_root_url()
+ self.html_uri = '%s+yuitest/%s' % (root_url, self.test_path)
+ super(YUIAppServerTestCase, self).setUp()
+
+ runTest = AbstractYUITestCase.checkResults
+
+
+def make_suite(module_name):
+ return unittest.TestSuite([YUIAppServerTestCase(module_name)])
=== modified file 'setup.py'
--- setup.py 2011-09-05 15:42:27 +0000
+++ setup.py 2011-09-21 01:29:24 +0000
@@ -159,10 +159,12 @@
'killservice = lp.scripts.utilities.killservice:main',
'jsbuild = lp.scripts.utilities.js.jsbuild:main',
'run = canonical.launchpad.scripts.runlaunchpad:start_launchpad',
+ 'run-testapp = '
+ 'canonical.launchpad.scripts.runlaunchpad:start_testapp',
'harness = canonical.database.harness:python',
'twistd = twisted.scripts.twistd:run',
- 'start_librarian '
- '= canonical.launchpad.scripts.runlaunchpad:start_librarian',
+ 'start_librarian = '
+ 'canonical.launchpad.scripts.runlaunchpad:start_librarian',
'ec2 = devscripts.ec2test.entrypoint:main',
]
),
=== added symlink 'standard_yuixhr_test_template.js'
=== target is u'lib/lp/testing/tests/test_standard_yuixhr_test_template.js'
=== added symlink 'standard_yuixhr_test_template.py'
=== target is u'lib/lp/testing/tests/test_standard_yuixhr_test_template.py'