~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/testing/yuixhr.py

  • Committer: Launchpad Patch Queue Manager
  • Date: 2011-09-22 19:59:40 UTC
  • mfrom: (13992.1.4 yuixhr)
  • Revision ID: launchpad@pqm.canonical.com-20110922195940-7y8d2fzivvo7fuoy
[r=deryck][bug=724609][no-qa][incr] Add the ability to write app
        integration tests with YUI.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2011 Canonical Ltd.  This software is licensed under the
 
2
# GNU Affero General Public License version 3 (see the file LICENSE).
 
3
 
 
4
"""Fixture code for YUITest + XHR integration testing."""
 
5
 
 
6
__metaclass__ = type
 
7
__all__ = [
 
8
    'login_as_person',
 
9
    'make_suite',
 
10
    'setup',
 
11
    'YUITestFixtureControllerView',
 
12
]
 
13
 
 
14
import os
 
15
import simplejson
 
16
import sys
 
17
from textwrap import dedent
 
18
import traceback
 
19
import unittest
 
20
 
 
21
from lazr.restful import ResourceJSONEncoder
 
22
from lazr.restful.utils import get_current_browser_request
 
23
from zope.component import getUtility
 
24
from zope.exceptions.exceptionformatter import format_exception
 
25
from zope.interface import implements
 
26
from zope.publisher.interfaces import NotFound
 
27
from zope.publisher.interfaces.http import IResult
 
28
from zope.security.checker import (
 
29
    NamesChecker,
 
30
    ProxyFactory)
 
31
from zope.security.proxy import removeSecurityProxy
 
32
from zope.session.interfaces import IClientIdManager
 
33
 
 
34
from canonical.config import config
 
35
from canonical.launchpad.webapp.interfaces import (
 
36
    IPlacelessAuthUtility,
 
37
    IOpenLaunchBag,
 
38
    )
 
39
from canonical.launchpad.webapp.login import logInPrincipal
 
40
from canonical.launchpad.webapp.publisher import LaunchpadView
 
41
from canonical.testing.layers import (
 
42
    DatabaseLayer,
 
43
    LaunchpadLayer,
 
44
    LibrarianLayer,
 
45
    LayerProcessController,
 
46
    YUIAppServerLayer,
 
47
    )
 
48
from lp.app.versioninfo import revno
 
49
from lp.testing import AbstractYUITestCase
 
50
 
 
51
EXPLOSIVE_ERRORS = (SystemExit, MemoryError, KeyboardInterrupt)
 
52
 
 
53
 
 
54
class setup:
 
55
    """Decorator to mark a function as a fixture available from JavaScript.
 
56
 
 
57
    This makes the function available to call from JS integration tests over
 
58
    XHR.  The fixture setup can have one or more cleanups tied to it with
 
59
    ``add_cleanup`` decorator/callable and can be composed with another
 
60
    function with the ``extend`` decorator/callable.
 
61
    """
 
62
    def __init__(self, function, extends=None):
 
63
        self._cleanups = []
 
64
        self._function = function
 
65
        self._extends = extends
 
66
        # We can't use locals because we want to affect the function's module,
 
67
        # not this one.
 
68
        module = sys.modules[function.__module__]
 
69
        fixtures = getattr(module, '_fixtures_', None)
 
70
        if fixtures is None:
 
71
            fixtures = module._fixtures_ = {}
 
72
        fixtures[function.__name__] = self
 
73
 
 
74
    def __call__(self, request, data):
 
75
        """Call the originally decorated setup function."""
 
76
        if self._extends is not None:
 
77
            self._extends(request, data)
 
78
        self._function(request, data)
 
79
 
 
80
    def add_cleanup(self, function):
 
81
        """Add a cleanup function to be executed on teardown, FILO."""
 
82
        self._cleanups.append(function)
 
83
        return self
 
84
 
 
85
    def teardown(self, request, data):
 
86
        """Run all registered cleanups.  If no cleanups, a no-op."""
 
87
        for f in reversed(self._cleanups):
 
88
            f(request, data)
 
89
        if self._extends is not None:
 
90
            self._extends.teardown(request, data)
 
91
 
 
92
    def extend(self, function):
 
93
        return setup(function, self)
 
94
 
 
95
 
 
96
def login_as_person(person):
 
97
    """This is a helper function designed to be used within a fixture.
 
98
 
 
99
    Provide a person, such as one generated by LaunchpadObjectFactory, and
 
100
    the browser will become logged in as this person.
 
101
 
 
102
    Explicit tear-down is unnecessary because the database is reset at the end
 
103
    of every test, and the cookie is discarded.
 
104
    """
 
105
    if person.is_team:
 
106
        raise AssertionError("Please do not try to login as a team")
 
107
    email = removeSecurityProxy(person.preferredemail).email
 
108
    request = get_current_browser_request()
 
109
    assert request is not None, "We do not have a browser request."
 
110
    authutil = getUtility(IPlacelessAuthUtility)
 
111
    principal = authutil.getPrincipalByLogin(email, want_password=False)
 
112
    launchbag = getUtility(IOpenLaunchBag)
 
113
    launchbag.setLogin(email)
 
114
    logInPrincipal(request, principal, email)
 
115
 
 
116
 
 
117
class CloseDbResult:
 
118
    implements(IResult)
 
119
 
 
120
    # This is machinery, not content.  We specify our security checker here
 
121
    # directly for clarity.
 
122
    __Security_checker__ = NamesChecker(['next', '__iter__'])
 
123
 
 
124
    def __iter__(self):
 
125
        try:
 
126
            # Reset the session.
 
127
            LaunchpadLayer.resetSessionDb()
 
128
            # Yield control to asyncore for a second, just to be a
 
129
            # little bit nice.  We could be even nicer by moving this
 
130
            # whole teardown/setup dance to a thread and waiting for
 
131
            # it to be done, but there's not a (known) compelling need
 
132
            # for that right now, and doing it this way is slightly
 
133
            # simpler.
 
134
            yield ''
 
135
            DatabaseLayer.testSetUp()
 
136
            yield ''
 
137
            # Reset the librarian.
 
138
            LibrarianLayer.testTearDown()
 
139
            yield ''
 
140
            # Reset the database.
 
141
            DatabaseLayer.testTearDown()
 
142
            yield ''
 
143
            LibrarianLayer.testSetUp()
 
144
        except (SystemExit, KeyboardInterrupt):
 
145
            raise
 
146
        except:
 
147
            print "Hm, serious error when trying to clean up the test."
 
148
            traceback.print_exc()
 
149
        # We're done, so we can yield the body.
 
150
        yield '\n'
 
151
 
 
152
 
 
153
class YUITestFixtureControllerView(LaunchpadView):
 
154
    """Dynamically loads YUI test along their fixtures run over an app server.
 
155
    """
 
156
 
 
157
    JAVASCRIPT = 'JAVASCRIPT'
 
158
    HTML = 'HTML'
 
159
    SETUP = 'SETUP'
 
160
    TEARDOWN = 'TEARDOWN'
 
161
 
 
162
    page_template = dedent("""\
 
163
        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
 
164
          "http://www.w3.org/TR/html4/strict.dtd">
 
165
        <html>
 
166
          <head>
 
167
          <title>Test</title>
 
168
          <script type="text/javascript"
 
169
            src="/+icing/rev%(revno)s/build/launchpad.js"></script>
 
170
          <link rel="stylesheet"
 
171
            href="/+icing/yui/assets/skins/sam/skin.css"/>
 
172
          <link rel="stylesheet" href="/+icing/rev%(revno)s/combo.css"/>
 
173
          <style>
 
174
          /* Taken and customized from testlogger.css */
 
175
          .yui-console-entry-src { display:none; }
 
176
          .yui-console-entry.yui-console-entry-pass .yui-console-entry-cat {
 
177
            background-color: green;
 
178
            font-weight: bold;
 
179
            color: white;
 
180
          }
 
181
          .yui-console-entry.yui-console-entry-fail .yui-console-entry-cat {
 
182
            background-color: red;
 
183
            font-weight: bold;
 
184
            color: white;
 
185
          }
 
186
          .yui-console-entry.yui-console-entry-ignore .yui-console-entry-cat {
 
187
            background-color: #666;
 
188
            font-weight: bold;
 
189
            color: white;
 
190
          }
 
191
          </style>
 
192
          <script type="text/javascript" src="%(test_module)s"></script>
 
193
        </head>
 
194
        <body class="yui3-skin-sam">
 
195
          <div id="log"></div>
 
196
        </body>
 
197
        </html>
 
198
        """)
 
199
 
 
200
    def __init__(self, context, request):
 
201
        super(YUITestFixtureControllerView, self).__init__(context, request)
 
202
        self.names = []
 
203
        self.action = None
 
204
        self.fixtures = []
 
205
 
 
206
    @property
 
207
    def traversed_path(self):
 
208
        return os.path.join(*self.names)
 
209
 
 
210
    def initialize(self):
 
211
        path, ext = os.path.splitext(self.traversed_path)
 
212
        full_path = os.path.join(config.root, 'lib', path)
 
213
        if not os.path.exists(full_path + '.py'):
 
214
            raise NotFound(self, full_path + '.py', self.request)
 
215
        if not os.path.exists(full_path + '.js'):
 
216
            raise NotFound(self, full_path + '.js', self.request)
 
217
 
 
218
        if ext == '.js':
 
219
            self.action = self.JAVASCRIPT
 
220
        else:
 
221
            if self.request.method == 'GET':
 
222
                self.action = self.HTML
 
223
            else:
 
224
                self.fixtures = self.request.form['fixtures'].split(',')
 
225
                if self.request.form['action'] == 'setup':
 
226
                    self.action = self.SETUP
 
227
                else:
 
228
                    self.action = self.TEARDOWN
 
229
 
 
230
    # The following two zope methods publishTraverse and browserDefault
 
231
    # allow this view class to take control of traversal from this point
 
232
    # onwards.  Traversed names just end up in self.names.
 
233
    def publishTraverse(self, request, name):
 
234
        """Traverse to the given name."""
 
235
        # The two following constraints are enforced by the publisher.
 
236
        assert os.path.sep not in name, (
 
237
            'traversed name contains os.path.sep: %s' % name)
 
238
        assert name != '..', 'traversing to ..'
 
239
        self.names.append(name)
 
240
        return self
 
241
 
 
242
    def browserDefault(self, request):
 
243
        return self, ()
 
244
 
 
245
    def page(self):
 
246
        return self.page_template % dict(
 
247
            test_module='/+yuitest/%s.js' % self.traversed_path,
 
248
            revno=revno)
 
249
 
 
250
    def get_fixtures(self):
 
251
        module_name = '.'.join(self.names)
 
252
        test_module = __import__(
 
253
            module_name, globals(), locals(), ['_fixtures_'], 0)
 
254
        return test_module._fixtures_
 
255
 
 
256
    def render(self):
 
257
        if self.action == self.JAVASCRIPT:
 
258
            self.request.response.setHeader('Content-Type', 'text/javascript')
 
259
            result = open(
 
260
                os.path.join(config.root, 'lib', self.traversed_path))
 
261
        elif self.action == self.HTML:
 
262
            self.request.response.setHeader('Content-Type', 'text/html')
 
263
            result = self.page()
 
264
        elif self.action == self.SETUP:
 
265
            data = {}
 
266
            fixtures = self.get_fixtures()
 
267
            try:
 
268
                for fixture_name in self.fixtures:
 
269
                    __traceback_info__ = (fixture_name, data)
 
270
                    fixtures[fixture_name](self.request, data)
 
271
            except EXPLOSIVE_ERRORS:
 
272
                raise
 
273
            except:
 
274
                self.request.response.setStatus(500)
 
275
                result = ''.join(format_exception(*sys.exc_info()))
 
276
            else:
 
277
                self.request.response.setHeader(
 
278
                    'Content-Type', 'application/json')
 
279
                # We use the ProxyFactory so that the restful
 
280
                # redaction code is always used.
 
281
                result = simplejson.dumps(
 
282
                    ProxyFactory(data), cls=ResourceJSONEncoder)
 
283
        elif self.action == self.TEARDOWN:
 
284
            data = simplejson.loads(self.request.form['data'])
 
285
            fixtures = self.get_fixtures()
 
286
            try:
 
287
                for fixture_name in reversed(self.fixtures):
 
288
                    __traceback_info__ = (fixture_name, data)
 
289
                    fixtures[fixture_name].teardown(self.request, data)
 
290
            except EXPLOSIVE_ERRORS:
 
291
                raise
 
292
            except:
 
293
                self.request.response.setStatus(500)
 
294
                result = ''.join(format_exception(*sys.exc_info()))
 
295
            else:
 
296
                # Remove the session cookie, in case we have one.
 
297
                self.request.response.expireCookie(
 
298
                    getUtility(IClientIdManager).namespace)
 
299
                # Blow up the database once we are out of this transaction
 
300
                # by passing a result that will do so when it is iterated
 
301
                # through in asyncore.
 
302
                self.request.response.setHeader('Content-Length', 1)
 
303
                result = CloseDbResult()
 
304
        return result
 
305
 
 
306
 
 
307
# This class cannot be imported directly into a test suite because
 
308
# then the test loader will sniff and (try to) run it.  Use make_suite
 
309
# instead (or import this module rather than this class).
 
310
class YUIAppServerTestCase(AbstractYUITestCase):
 
311
    "Instantiate this test case with the Python fixture module name."
 
312
 
 
313
    layer = YUIAppServerLayer
 
314
    _testMethodName = 'runTest'
 
315
 
 
316
    def __init__(self, module_name=None):
 
317
        self.module_name = module_name
 
318
        # This needs to be done early so the "id" is set correctly.
 
319
        self.test_path = self.module_name.replace('.', '/')
 
320
        super(YUIAppServerTestCase, self).__init__()
 
321
 
 
322
    def setUp(self):
 
323
        root_url = LayerProcessController.appserver_root_url()
 
324
        self.html_uri = '%s+yuitest/%s' % (root_url, self.test_path)
 
325
        super(YUIAppServerTestCase, self).setUp()
 
326
 
 
327
    runTest = AbstractYUITestCase.checkResults
 
328
 
 
329
 
 
330
def make_suite(module_name):
 
331
    return unittest.TestSuite([YUIAppServerTestCase(module_name)])