# -*- coding: utf-8 -*-
'''
This module provide Javascript test runners for Django unittest.
'''
from __future__ import unicode_literals
import os
import re
import sys
from os.path import join, dirname
from subprocess import Popen, STDOUT, PIPE
from tempfile import NamedTemporaryFile, mkstemp
from django.contrib.staticfiles import finders
from django.core.files.storage import FileSystemStorage
from django.core.urlresolvers import reverse
from django.template.loader import render_to_string
from django.test import LiveServerTestCase
from django.utils.encoding import python_2_unicode_compatible
from djangojs.tap import TapParser
from djangojs.utils import StorageGlobber
from django.utils.unittest import TestCase
#: Console output line length for separators
LINE_SIZE = 70
__all__ = (
'JsTestCase',
'JsFileTestCase',
'JsTemplateTestCase',
'JsTestException',
'JasmineSuite',
'QUnitSuite',
'AbsoluteFileStorage',
)
LAUNCHERS = {
'jasmine': join(dirname(__file__), 'phantomjs', 'jasmine-runner.js'),
'qunit': join(dirname(__file__), 'phantomjs', 'qunit-runner.js')
}
def parse_verbosity():
verbosity = 1
regex = re.compile(r'(?:-v|--verbosity)(?:(?:\=)?([0-3]))?')
for idx, arg in enumerate(sys.argv):
match = regex.match(arg)
if match:
verbosity = int(match.group(1)) if match.group(1) else int(sys.argv[idx + 1])
break
return verbosity
VERBOSITY = parse_verbosity()
VERBOSE = VERBOSITY > 1
@python_2_unicode_compatible
[docs]class JsTestException(Exception):
'''
An exception raised by Javascript tests.
It display javascript errors into the exception message.
'''
def __init__(self, message, failures=None):
super(JsTestException, self).__init__(message)
self.failures = failures or []
def __str__(self):
output = [super(JsTestException, self).__str__()]
for failure in self.failures:
message = failure.message or "expected: '%(expected)s', got: '%(got)s'" % failure.__dict__
output.append(' + %s - %s' % (failure.parent.name, message))
for line in failure.stack:
# Remove jasmine/qunit entries in stack to produce smaller and more efficient output
# TODO: find a cleaner way to improve stack trace output
if '/js/test/libs/jasmine.js:' in line or '/js/test/libs/qunit.js:' in line:
continue
output.append('\t%s' % line)
return '\n'.join(output)
class PhantomJsRunner(object):
'''
Test helper to run JS tests with PhantomJS
'''
#: mandatory path to the PhantomJS javascript runner
phantomjs_runner = None
#: an optionnal absolute URL to the test runner page
url = None
#: an optionnal title for verbose console output
title = 'PhantomJS test suite'
#: PhantomJS execution timeout in seconds
timeout = 3
def execute(self, command):
'''
Execute a subprocess yielding output lines
'''
process = Popen(command, stdout=PIPE, stderr=STDOUT, universal_newlines=True)
while True:
if process.poll() is not None:
self.returncode = process.returncode # pylint: disable=W0201
break
yield process.stdout.readline()
def phantomjs(self, *args, **kwargs):
'''
Execute PhantomJS by giving ``args`` as command line arguments.
If test are run in verbose mode (``-v/--verbosity`` = 2), it output:
- the title as header (with separators before and after)
- modules and test names
- assertions results (with ``django.utils.termcolors`` support)
In case of error, a JsTestException is raised to give details about javascript errors.
'''
separator = '=' * LINE_SIZE
title = kwargs['title'] if 'title' in kwargs else 'phantomjs output'
nb_spaces = (LINE_SIZE - len(title)) // 2
if VERBOSE:
print('')
print(separator)
print(' ' * nb_spaces + title)
print(separator)
sys.stdout.flush()
with NamedTemporaryFile(delete=True) as cookies_file:
cmd = ('phantomjs', '--cookies-file=%s' % cookies_file.name) + args
if self.timeout:
cmd += (str(self.timeout),)
parser = TapParser(debug=VERBOSITY > 2)
output = self.execute(cmd)
for item in parser.parse(output):
if VERBOSE:
print(item.display())
sys.stdout.flush()
if VERBOSE:
print(separator)
sys.stdout.flush()
failures = parser.suites.get_all_failures()
if failures:
raise JsTestException('Failed javascript assertions', failures)
if self.returncode > 0:
raise JsTestException('PhantomJS return with non-zero exit code (%s)' % self.returncode)
def run_suite(self):
'''
Run a phantomjs test suite.
- ``phantomjs_runner`` is mandatory.
- Either ``url`` or ``url_name`` needs to be defined.
'''
if not self.phantomjs_runner:
raise JsTestException('phantomjs_runner need to be defined')
url = self.get_url()
self.phantomjs(self.phantomjs_runner, url, title=self.title)
self.cleanup()
def get_url(self):
if not self.url:
raise JsTestException('url need to be defined')
return self.url
def cleanup(self):
pass
[docs]class JsTestCase(PhantomJsRunner, LiveServerTestCase):
'''
A PhantomJS suite that run against the Django LiveServerTestCase
'''
#: a mandatory named URL that point to the test runner page
url_name = None
#: an optionnal arguments array to pass to the ``reverse()`` function
url_args = None
#: an optionnal keyword arguments dictionnary to pass to the ``reverse()`` function
url_kwargs = None
def get_url(self):
if not self.url_name:
raise JsTestException('url_name need to be defined')
reversed_url = reverse(self.url_name, args=self.url_args, kwargs=self.url_kwargs)
return ''.join([self.live_server_url, reversed_url])
[docs]class JsFileTestCase(PhantomJsRunner, TestCase):
'''
A PhantomJS suite that run against a local html file
'''
#: absolute path to the test runner page
filename = None
def get_url(self):
if not self.filename:
raise JsTestException('filename need to be defined')
return 'file://%s' % self.filename
[docs]class AbsoluteFileStorage(FileSystemStorage):
'''
A storage that give the absolute file scheme URL as URL.
'''
def url(self, name):
return 'file://%s' % finders.find(name)
[docs]class JsTemplateTestCase(JsFileTestCase):
'''
A PhantomJS suite that run against a rendered html file but without server.
.. note::
Template is rendered using a modified static storage that give ``file://`` scheme URLs.
To benefits from it, you have to use either the ``static`` template tag
or one the djangojs template tags.
.. warning::
Template is not rendered within a request/response dialog.
You can't access the request object and everything that depends on the server.
'''
#: absolute path to the test runner page
template_name = None
#: A path or a list of path to javascript files to include into the view.
#:
#: - Supports glob patterns.
#: - Order is kept for rendering.
js_files = None
#: Includes or not jQuery in the test view.
#: Template has to handle the ``use_jquery`` property.
jquery = False
def setUp(self):
from django.contrib.staticfiles.storage import staticfiles_storage
self.storage_bak = staticfiles_storage._wrapped
staticfiles_storage._wrapped = AbsoluteFileStorage()
def tearDown(self):
from django.contrib.staticfiles.storage import staticfiles_storage
staticfiles_storage._wrapped = self.storage_bak
def get_url(self):
if not self.template_name:
raise JsTestException('template_name need to be defined')
fd, self.filename = mkstemp(suffix='.html')
os.fdopen(fd, 'w').write(render_to_string(self.template_name, self.get_context_data()))
return super(JsTemplateTestCase, self).get_url()
def get_context_data(self, **kwargs):
return {
'js_test_files': StorageGlobber.glob(self.js_files),
'use_query': self.jquery
}
def cleanup(self):
os.remove(self.filename)
[docs]class JasmineSuite(object):
'''
A mixin that runs a jasmine test suite with PhantomJs.
'''
title = 'Jasmine test suite'
phantomjs_runner = LAUNCHERS['jasmine']
template_name = 'djangojs/jasmine-runner.html'
def test(self):
self.run_suite()
[docs]class QUnitSuite(object):
'''
A mixin that runs a QUnit test suite with PhantomJs.
'''
title = 'QUnit test suite'
phantomjs_runner = LAUNCHERS['qunit']
template_name = 'djangojs/qunit-runner.html'
def test(self):
self.run_suite()