~launchpad-pqm/launchpad/devel

13992.1.1 by Gary Poster
Add yui xhr integration test support.
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
14151.2.1 by Gary Poster
add yuixhr test improvements back
14
from fnmatch import fnmatchcase
13992.1.1 by Gary Poster
Add yui xhr integration test support.
15
import os
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
14550.1.1 by Steve Kowalik
Run format-imports over lib/lp and lib/canonical/launchpad
23
import simplejson
13992.1.1 by Gary Poster
Add yui xhr integration test support.
24
from zope.component import getUtility
25
from zope.exceptions.exceptionformatter import format_exception
26
from zope.interface import implements
27
from zope.publisher.interfaces import NotFound
28
from zope.publisher.interfaces.http import IResult
29
from zope.security.checker import (
30
    NamesChecker,
14550.1.1 by Steve Kowalik
Run format-imports over lib/lp and lib/canonical/launchpad
31
    ProxyFactory,
32
    )
13992.1.1 by Gary Poster
Add yui xhr integration test support.
33
from zope.security.proxy import removeSecurityProxy
34
from zope.session.interfaces import IClientIdManager
35
14612.2.1 by William Grant
format-imports on lib/. So many imports.
36
from lp.app.versioninfo import revno
14605.1.1 by Curtis Hovey
Moved canonical.config to lp.services.
37
from lp.services.config import config
14600.2.2 by Curtis Hovey
Moved webapp to lp.services.
38
from lp.services.webapp.interfaces import (
14550.1.1 by Steve Kowalik
Run format-imports over lib/lp and lib/canonical/launchpad
39
    IOpenLaunchBag,
13992.1.1 by Gary Poster
Add yui xhr integration test support.
40
    IPlacelessAuthUtility,
41
    )
14600.2.2 by Curtis Hovey
Moved webapp to lp.services.
42
from lp.services.webapp.login import logInPrincipal
43
from lp.services.webapp.publisher import LaunchpadView
14612.2.1 by William Grant
format-imports on lib/. So many imports.
44
from lp.testing import AbstractYUITestCase
14604.1.1 by Curtis Hovey
Separate test-authoring classes from test-running classes.
45
from lp.testing.layers import (
13992.1.1 by Gary Poster
Add yui xhr integration test support.
46
    DatabaseLayer,
47
    LaunchpadLayer,
14550.1.1 by Steve Kowalik
Run format-imports over lib/lp and lib/canonical/launchpad
48
    LayerProcessController,
13992.1.1 by Gary Poster
Add yui xhr integration test support.
49
    LibrarianLayer,
50
    YUIAppServerLayer,
51
    )
52
14550.1.1 by Steve Kowalik
Run format-imports over lib/lp and lib/canonical/launchpad
53
13992.1.1 by Gary Poster
Add yui xhr integration test support.
54
EXPLOSIVE_ERRORS = (SystemExit, MemoryError, KeyboardInterrupt)
55
13992.1.3 by Gary Poster
clean more lint
56
13992.1.1 by Gary Poster
Add yui xhr integration test support.
57
class setup:
58
    """Decorator to mark a function as a fixture available from JavaScript.
59
60
    This makes the function available to call from JS integration tests over
61
    XHR.  The fixture setup can have one or more cleanups tied to it with
62
    ``add_cleanup`` decorator/callable and can be composed with another
63
    function with the ``extend`` decorator/callable.
64
    """
65
    def __init__(self, function, extends=None):
66
        self._cleanups = []
67
        self._function = function
68
        self._extends = extends
69
        # We can't use locals because we want to affect the function's module,
70
        # not this one.
71
        module = sys.modules[function.__module__]
72
        fixtures = getattr(module, '_fixtures_', None)
73
        if fixtures is None:
74
            fixtures = module._fixtures_ = {}
75
        fixtures[function.__name__] = self
76
77
    def __call__(self, request, data):
78
        """Call the originally decorated setup function."""
79
        if self._extends is not None:
80
            self._extends(request, data)
81
        self._function(request, data)
82
83
    def add_cleanup(self, function):
84
        """Add a cleanup function to be executed on teardown, FILO."""
85
        self._cleanups.append(function)
86
        return self
87
88
    def teardown(self, request, data):
89
        """Run all registered cleanups.  If no cleanups, a no-op."""
90
        for f in reversed(self._cleanups):
91
            f(request, data)
92
        if self._extends is not None:
93
            self._extends.teardown(request, data)
94
95
    def extend(self, function):
96
        return setup(function, self)
97
98
99
def login_as_person(person):
100
    """This is a helper function designed to be used within a fixture.
13992.1.3 by Gary Poster
clean more lint
101
13992.1.1 by Gary Poster
Add yui xhr integration test support.
102
    Provide a person, such as one generated by LaunchpadObjectFactory, and
103
    the browser will become logged in as this person.
13992.1.3 by Gary Poster
clean more lint
104
13992.1.1 by Gary Poster
Add yui xhr integration test support.
105
    Explicit tear-down is unnecessary because the database is reset at the end
106
    of every test, and the cookie is discarded.
107
    """
108
    if person.is_team:
109
        raise AssertionError("Please do not try to login as a team")
110
    email = removeSecurityProxy(person.preferredemail).email
111
    request = get_current_browser_request()
112
    assert request is not None, "We do not have a browser request."
113
    authutil = getUtility(IPlacelessAuthUtility)
114
    principal = authutil.getPrincipalByLogin(email, want_password=False)
115
    launchbag = getUtility(IOpenLaunchBag)
116
    launchbag.setLogin(email)
117
    logInPrincipal(request, principal, email)
118
119
120
class CloseDbResult:
121
    implements(IResult)
122
123
    # This is machinery, not content.  We specify our security checker here
124
    # directly for clarity.
125
    __Security_checker__ = NamesChecker(['next', '__iter__'])
126
127
    def __iter__(self):
128
        try:
129
            # Reset the session.
130
            LaunchpadLayer.resetSessionDb()
13992.1.3 by Gary Poster
clean more lint
131
            # Yield control to asyncore for a second, just to be a
132
            # little bit nice.  We could be even nicer by moving this
133
            # whole teardown/setup dance to a thread and waiting for
134
            # it to be done, but there's not a (known) compelling need
135
            # for that right now, and doing it this way is slightly
136
            # simpler.
137
            yield ''
13992.1.1 by Gary Poster
Add yui xhr integration test support.
138
            DatabaseLayer.testSetUp()
13992.1.3 by Gary Poster
clean more lint
139
            yield ''
13992.1.1 by Gary Poster
Add yui xhr integration test support.
140
            # Reset the librarian.
141
            LibrarianLayer.testTearDown()
13992.1.3 by Gary Poster
clean more lint
142
            yield ''
13992.1.1 by Gary Poster
Add yui xhr integration test support.
143
            # Reset the database.
144
            DatabaseLayer.testTearDown()
13992.1.3 by Gary Poster
clean more lint
145
            yield ''
13992.1.1 by Gary Poster
Add yui xhr integration test support.
146
            LibrarianLayer.testSetUp()
147
        except (SystemExit, KeyboardInterrupt):
148
            raise
149
        except:
150
            print "Hm, serious error when trying to clean up the test."
151
            traceback.print_exc()
152
        # We're done, so we can yield the body.
153
        yield '\n'
154
155
156
class YUITestFixtureControllerView(LaunchpadView):
157
    """Dynamically loads YUI test along their fixtures run over an app server.
158
    """
159
160
    JAVASCRIPT = 'JAVASCRIPT'
161
    HTML = 'HTML'
162
    SETUP = 'SETUP'
163
    TEARDOWN = 'TEARDOWN'
14151.2.1 by Gary Poster
add yuixhr test improvements back
164
    INDEX = 'INDEX'
13992.1.1 by Gary Poster
Add yui xhr integration test support.
165
166
    page_template = dedent("""\
167
        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
168
          "http://www.w3.org/TR/html4/strict.dtd">
169
        <html>
170
          <head>
171
          <title>Test</title>
172
          <script type="text/javascript"
173
            src="/+icing/rev%(revno)s/build/launchpad.js"></script>
174
          <link rel="stylesheet"
175
            href="/+icing/yui/assets/skins/sam/skin.css"/>
176
          <link rel="stylesheet" href="/+icing/rev%(revno)s/combo.css"/>
177
          <style>
178
          /* Taken and customized from testlogger.css */
179
          .yui-console-entry-src { display:none; }
180
          .yui-console-entry.yui-console-entry-pass .yui-console-entry-cat {
181
            background-color: green;
182
            font-weight: bold;
183
            color: white;
184
          }
185
          .yui-console-entry.yui-console-entry-fail .yui-console-entry-cat {
186
            background-color: red;
187
            font-weight: bold;
188
            color: white;
189
          }
190
          .yui-console-entry.yui-console-entry-ignore .yui-console-entry-cat {
191
            background-color: #666;
192
            font-weight: bold;
193
            color: white;
194
          }
195
          </style>
196
          <script type="text/javascript" src="%(test_module)s"></script>
197
        </head>
198
        <body class="yui3-skin-sam">
199
          <div id="log"></div>
14151.2.1 by Gary Poster
add yuixhr test improvements back
200
          <p>Want to re-run your test?</p>
201
          <ul>
202
            <li><a href="?">Reload test JS</a></li>
203
            <li><a href="?reload=1">Reload test JS and the associated
204
                                    Python fixtures</a></li>
205
          </ul>
206
          <p>Don't forget to run <code>make jsbuild</code> and then do a
207
             hard reload of this page if you change a file that is built
208
             into launchpad.js!</p>
209
          <p>If you change Python code other than the fixtures, you must
210
             restart the server.  Sorry.</p>
211
        </body>
212
        </html>
213
        """)
214
215
    index_template = dedent("""\
216
        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
217
          "http://www.w3.org/TR/html4/strict.dtd">
218
        <html>
219
          <head>
220
          <title>YUI XHR Tests</title>
221
          <script type="text/javascript"
222
            src="/+icing/rev%(revno)s/build/launchpad.js"></script>
223
          <link rel="stylesheet"
224
            href="/+icing/yui/assets/skins/sam/skin.css"/>
225
          <link rel="stylesheet" href="/+icing/rev%(revno)s/combo.css"/>
226
          <style>
227
          ul {
228
            text-align: left;
229
          }
230
          body, ul, h1 {
231
            margin: 0.3em;
232
            padding: 0.3em;
233
          }
234
        </style>
235
        </head>
236
        <body class="yui3-skin-sam">
237
          <h1>YUI XHR Tests</h1>
238
          <ul>%(tests)s</ul>
13992.1.1 by Gary Poster
Add yui xhr integration test support.
239
        </body>
240
        </html>
241
        """)
242
243
    def __init__(self, context, request):
244
        super(YUITestFixtureControllerView, self).__init__(context, request)
245
        self.names = []
246
        self.action = None
247
        self.fixtures = []
248
249
    @property
250
    def traversed_path(self):
251
        return os.path.join(*self.names)
252
253
    def initialize(self):
14151.2.1 by Gary Poster
add yuixhr test improvements back
254
        if not self.names:
255
            self.action = self.INDEX
256
            return
13992.1.1 by Gary Poster
Add yui xhr integration test support.
257
        path, ext = os.path.splitext(self.traversed_path)
258
        full_path = os.path.join(config.root, 'lib', path)
259
        if not os.path.exists(full_path + '.py'):
260
            raise NotFound(self, full_path + '.py', self.request)
261
        if not os.path.exists(full_path + '.js'):
262
            raise NotFound(self, full_path + '.js', self.request)
263
264
        if ext == '.js':
265
            self.action = self.JAVASCRIPT
266
        else:
267
            if self.request.method == 'GET':
268
                self.action = self.HTML
269
            else:
270
                self.fixtures = self.request.form['fixtures'].split(',')
271
                if self.request.form['action'] == 'setup':
272
                    self.action = self.SETUP
273
                else:
274
                    self.action = self.TEARDOWN
275
276
    # The following two zope methods publishTraverse and browserDefault
277
    # allow this view class to take control of traversal from this point
278
    # onwards.  Traversed names just end up in self.names.
279
    def publishTraverse(self, request, name):
280
        """Traverse to the given name."""
281
        # The two following constraints are enforced by the publisher.
282
        assert os.path.sep not in name, (
283
            'traversed name contains os.path.sep: %s' % name)
284
        assert name != '..', 'traversing to ..'
14151.2.1 by Gary Poster
add yuixhr test improvements back
285
        if name:
286
            self.names.append(name)
13992.1.1 by Gary Poster
Add yui xhr integration test support.
287
        return self
288
289
    def browserDefault(self, request):
290
        return self, ()
291
14151.2.1 by Gary Poster
add yuixhr test improvements back
292
    @property
293
    def module_name(self):
294
        return '.'.join(self.names)
295
296
    def get_fixtures(self):
297
        module = __import__(
298
            self.module_name, globals(), locals(), ['_fixtures_'], 0)
299
        return module._fixtures_
300
301
    def renderINDEX(self):
302
        root = os.path.join(config.root, 'lib')
303
        test_lines = []
304
        for path in find_tests(root):
305
            test_path = '/+yuitest/' + '/'.join(path)
306
            module_name = '.'.join(path)
307
            try:
308
                module = __import__(
309
                    module_name, globals(), locals(), ['test_suite'], 0)
310
            except ImportError:
311
                warning = 'cannot import Python fixture file'
312
            else:
313
                try:
314
                    suite_factory = module.test_suite
315
                except AttributeError:
316
                    warning = 'cannot find test_suite'
317
                else:
318
                    try:
319
                        suite = suite_factory()
320
                    except EXPLOSIVE_ERRORS:
321
                        raise
322
                    except:
323
                        warning = 'test_suite raises errors'
324
                    else:
325
                        case = None
326
                        for case in suite:
327
                            if isinstance(case, YUIAppServerTestCase):
328
                                root_url = config.appserver_root_url(
329
                                    case.facet)
330
                                if root_url != 'None':
331
                                    test_path = root_url + test_path
332
                                warning = ''
333
                                break
334
                        else:
335
                            warning = (
336
                                'test suite is not instance of '
337
                                'YUIAppServerTestCase')
338
            link = '<a href="%s">%s</a>' % (test_path, test_path)
339
            if warning:
340
                warning = ' <span class="warning">%s</span>' % warning
341
            test_lines.append('<li>%s%s</li>' % (link, warning))
342
        return self.index_template % {
343
            'revno': revno,
344
            'tests': '\n'.join(test_lines)}
345
346
    def renderJAVASCRIPT(self):
347
        self.request.response.setHeader('Content-Type', 'text/javascript')
348
        self.request.response.setHeader('Cache-Control', 'no-cache')
349
        return open(
350
            os.path.join(config.root, 'lib', self.traversed_path))
351
352
    def renderHTML(self):
353
        self.request.response.setHeader('Content-Type', 'text/html')
354
        self.request.response.setHeader('Cache-Control', 'no-cache')
355
        if ('INTERACTIVE_TESTS' in os.environ and
356
            'reload' in self.request.form):
357
            # We should try to reload the module.
358
            module = sys.modules.get(self.module_name)
359
            if module is not None:
360
                del module._fixtures_
361
                reload(module)
13992.1.1 by Gary Poster
Add yui xhr integration test support.
362
        return self.page_template % dict(
363
            test_module='/+yuitest/%s.js' % self.traversed_path,
364
            revno=revno)
365
14151.2.1 by Gary Poster
add yuixhr test improvements back
366
    def renderSETUP(self):
367
        data = {}
368
        fixtures = self.get_fixtures()
369
        try:
370
            for fixture_name in self.fixtures:
371
                __traceback_info__ = (fixture_name, data)
372
                fixtures[fixture_name](self.request, data)
373
        except EXPLOSIVE_ERRORS:
374
            raise
375
        except:
376
            self.request.response.setStatus(500)
377
            result = ''.join(format_exception(*sys.exc_info()))
378
        else:
379
            self.request.response.setHeader(
380
                'Content-Type', 'application/json')
381
            # We use the ProxyFactory so that the restful
382
            # redaction code is always used.
383
            result = simplejson.dumps(
384
                ProxyFactory(data), cls=ResourceJSONEncoder)
385
        return result
386
387
    def renderTEARDOWN(self):
388
        data = simplejson.loads(self.request.form['data'])
389
        fixtures = self.get_fixtures()
390
        try:
391
            for fixture_name in reversed(self.fixtures):
392
                __traceback_info__ = (fixture_name, data)
393
                fixtures[fixture_name].teardown(self.request, data)
394
        except EXPLOSIVE_ERRORS:
395
            raise
396
        except:
397
            self.request.response.setStatus(500)
398
            result = ''.join(format_exception(*sys.exc_info()))
399
        else:
400
            # Remove the session cookie, in case we have one.
401
            self.request.response.expireCookie(
402
                getUtility(IClientIdManager).namespace)
403
            # Blow up the database once we are out of this transaction
404
            # by passing a result that will do so when it is iterated
405
            # through in asyncore.
406
            self.request.response.setHeader('Content-Length', 1)
407
            result = CloseDbResult()
408
        return result
13992.1.1 by Gary Poster
Add yui xhr integration test support.
409
410
    def render(self):
14151.2.1 by Gary Poster
add yuixhr test improvements back
411
        return getattr(self, 'render' + self.action)()
412
413
414
def find_tests(root):
415
    for dirpath, dirnames, filenames in os.walk(root):
416
        dirpath = os.path.relpath(dirpath, root)
417
        for filename in filenames:
418
            if fnmatchcase(filename, 'test_*.js'):
419
                name, ext = os.path.splitext(filename)
420
                if name + '.py' in filenames:
421
                    names = dirpath.split(os.path.sep)
422
                    names.append(name)
423
                    yield names
13992.1.1 by Gary Poster
Add yui xhr integration test support.
424
13992.1.3 by Gary Poster
clean more lint
425
13992.1.1 by Gary Poster
Add yui xhr integration test support.
426
# This class cannot be imported directly into a test suite because
427
# then the test loader will sniff and (try to) run it.  Use make_suite
428
# instead (or import this module rather than this class).
429
class YUIAppServerTestCase(AbstractYUITestCase):
430
    "Instantiate this test case with the Python fixture module name."
431
432
    layer = YUIAppServerLayer
433
    _testMethodName = 'runTest'
14151.2.4 by Gary Poster
incorporate timing limits per test on yuixhr tests
434
    # 5 minutes for the suite.  Hopefully we never get close to this.
435
    suite_timeout = 300000
14301.1.1 by Gary Poster
double the per-test timeout for yuixhr tests
436
    # 12 seconds for each test.  Hopefully they are three or less for
437
    # yuixhr tests, and less than one for pure JS tests, but
438
    # occasionally buildbot runs over six seconds even for tests that
439
    # are well-behaved locally and on ec2, so we up the limit to 12..
440
    incremental_timeout = 12000
14362.1.1 by Gary Poster
increase time for first yuixhr test. This is only necessary on buildbot, and it is not clear why.
441
    # 45 seconds for the first test, to include warmup time.  These times
442
    # are wildly large, and they are only necessary on buildbot.  ec2 and
443
    # local instances are much, much faster.  We have not yet investigated
444
    # why buildbot is so slow for these.
445
    initial_timeout = 45000
13992.1.1 by Gary Poster
Add yui xhr integration test support.
446
14151.2.1 by Gary Poster
add yuixhr test improvements back
447
    def __init__(self, module_name, facet='mainsite'):
13992.1.1 by Gary Poster
Add yui xhr integration test support.
448
        self.module_name = module_name
14151.2.1 by Gary Poster
add yuixhr test improvements back
449
        self.facet = facet
13992.1.1 by Gary Poster
Add yui xhr integration test support.
450
        # This needs to be done early so the "id" is set correctly.
451
        self.test_path = self.module_name.replace('.', '/')
452
        super(YUIAppServerTestCase, self).__init__()
453
454
    def setUp(self):
14151.2.1 by Gary Poster
add yuixhr test improvements back
455
        config = LayerProcessController.appserver_config
456
        root_url = config.appserver_root_url(self.facet)
457
        self.html_uri = '%s/+yuitest/%s' % (root_url, self.test_path)
13992.1.1 by Gary Poster
Add yui xhr integration test support.
458
        super(YUIAppServerTestCase, self).setUp()
459
460
    runTest = AbstractYUITestCase.checkResults
461
462
14151.2.1 by Gary Poster
add yuixhr test improvements back
463
def make_suite(module_name, facet='mainsite'):
464
    return unittest.TestSuite([YUIAppServerTestCase(module_name, facet)])