~launchpad-pqm/launchpad/devel

« back to all changes in this revision

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

  • Committer: Gary Poster
  • Date: 2011-09-20 22:33:07 UTC
  • mto: This revision was merged to the branch mainline in revision 14015.
  • Revision ID: gary.poster@canonical.com-20110920223307-zt1kr1px2ixjg9mn
Add yui xhr integration test support.

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.management import getInteraction
 
32
from zope.security.proxy import removeSecurityProxy
 
33
from zope.session.interfaces import IClientIdManager
 
34
 
 
35
from canonical.config import config
 
36
from canonical.launchpad.webapp.interfaces import (
 
37
    IPlacelessAuthUtility,
 
38
    IOpenLaunchBag,
 
39
    )
 
40
from canonical.launchpad.webapp.login import logInPrincipal
 
41
from canonical.launchpad.webapp.publisher import LaunchpadView
 
42
from canonical.testing.layers import (
 
43
    DatabaseLayer,
 
44
    LaunchpadLayer,
 
45
    LibrarianLayer,
 
46
    LayerProcessController,
 
47
    YUIAppServerLayer,
 
48
    )
 
49
from lp.app.versioninfo import revno
 
50
from lp.testing import AbstractYUITestCase
 
51
 
 
52
EXPLOSIVE_ERRORS = (SystemExit, MemoryError, KeyboardInterrupt)
 
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 little bit
 
129
            # nice.  We could be even nicer by moving this whole teardown/setup
 
130
            # dance to a thread and waiting for it to be done, but there's not a
 
131
            # (known) compelling need for that right now, and doing it this way is
 
132
            # slightly simpler.
 
133
            yield '' 
 
134
            DatabaseLayer.testSetUp()
 
135
            yield '' 
 
136
            # Reset the librarian.
 
137
            LibrarianLayer.testTearDown()
 
138
            yield '' 
 
139
            # Reset the database.
 
140
            DatabaseLayer.testTearDown()
 
141
            yield '' 
 
142
            LibrarianLayer.testSetUp()
 
143
        except (SystemExit, KeyboardInterrupt):
 
144
            raise
 
145
        except:
 
146
            print "Hm, serious error when trying to clean up the test."
 
147
            traceback.print_exc()
 
148
        # We're done, so we can yield the body.
 
149
        yield '\n'
 
150
 
 
151
 
 
152
class YUITestFixtureControllerView(LaunchpadView):
 
153
    """Dynamically loads YUI test along their fixtures run over an app server.
 
154
    """
 
155
 
 
156
    JAVASCRIPT = 'JAVASCRIPT'
 
157
    HTML = 'HTML'
 
158
    SETUP = 'SETUP'
 
159
    TEARDOWN = 'TEARDOWN'
 
160
 
 
161
    page_template = dedent("""\
 
162
        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
 
163
          "http://www.w3.org/TR/html4/strict.dtd">
 
164
        <html>
 
165
          <head>
 
166
          <title>Test</title>
 
167
          <script type="text/javascript"
 
168
            src="/+icing/rev%(revno)s/build/launchpad.js"></script>
 
169
          <link rel="stylesheet"
 
170
            href="/+icing/yui/assets/skins/sam/skin.css"/>
 
171
          <link rel="stylesheet" href="/+icing/rev%(revno)s/combo.css"/>
 
172
          <style>
 
173
          /* Taken and customized from testlogger.css */
 
174
          .yui-console-entry-src { display:none; }
 
175
          
 
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(os.path.join(config.root, 'lib', self.traversed_path))
 
260
        elif self.action == self.HTML:
 
261
            self.request.response.setHeader('Content-Type', 'text/html')
 
262
            result = self.page()
 
263
        elif self.action == self.SETUP:
 
264
            data = {}
 
265
            fixtures = self.get_fixtures()
 
266
            try:
 
267
                for fixture_name in self.fixtures:
 
268
                    __traceback_info__ = (fixture_name, data)
 
269
                    fixtures[fixture_name](self.request, data)
 
270
            except EXPLOSIVE_ERRORS:
 
271
                raise
 
272
            except:
 
273
                self.request.response.setStatus(500)
 
274
                result = ''.join(format_exception(*sys.exc_info()))
 
275
            else:
 
276
                self.request.response.setHeader(
 
277
                    'Content-Type', 'application/json')
 
278
                # We use the ProxyFactory so that the restful redaction code is
 
279
                # always used.
 
280
                result = simplejson.dumps(
 
281
                    ProxyFactory(data), cls=ResourceJSONEncoder)
 
282
        elif self.action == self.TEARDOWN:
 
283
            data = simplejson.loads(self.request.form['data'])
 
284
            fixtures = self.get_fixtures()
 
285
            try:
 
286
                for fixture_name in reversed(self.fixtures):
 
287
                    __traceback_info__ = (fixture_name, data)
 
288
                    fixtures[fixture_name].teardown(self.request, data)
 
289
            except EXPLOSIVE_ERRORS:
 
290
                raise
 
291
            except:
 
292
                self.request.response.setStatus(500)
 
293
                result = ''.join(format_exception(*sys.exc_info()))
 
294
            else:
 
295
                # Remove the session cookie, in case we have one.
 
296
                self.request.response.expireCookie(
 
297
                    getUtility(IClientIdManager).namespace)
 
298
                # Blow up the database once we are out of this transaction
 
299
                # by passing a result that will do so when it is iterated
 
300
                # through in asyncore.
 
301
                self.request.response.setHeader('Content-Length', 1)
 
302
                result = CloseDbResult()
 
303
        return result
 
304
 
 
305
# This class cannot be imported directly into a test suite because
 
306
# then the test loader will sniff and (try to) run it.  Use make_suite
 
307
# instead (or import this module rather than this class).
 
308
class YUIAppServerTestCase(AbstractYUITestCase):
 
309
    "Instantiate this test case with the Python fixture module name."
 
310
 
 
311
    layer = YUIAppServerLayer
 
312
    _testMethodName = 'runTest'
 
313
 
 
314
    def __init__(self, module_name=None):
 
315
        self.module_name = module_name
 
316
        # This needs to be done early so the "id" is set correctly.
 
317
        self.test_path = self.module_name.replace('.', '/')
 
318
        super(YUIAppServerTestCase, self).__init__()
 
319
 
 
320
    def setUp(self):
 
321
        root_url = LayerProcessController.appserver_root_url()
 
322
        self.html_uri = '%s+yuitest/%s' % (root_url, self.test_path)
 
323
        super(YUIAppServerTestCase, self).setUp()
 
324
 
 
325
    runTest = AbstractYUITestCase.checkResults
 
326
 
 
327
 
 
328
def make_suite(module_name):
 
329
    return unittest.TestSuite([YUIAppServerTestCase(module_name)])