From 4040b8caa653b3d35068f6751136b86bdcac8f3b Mon Sep 17 00:00:00 2001 From: Antony Lesuisse Date: Sun, 9 Dec 2012 03:48:10 +0100 Subject: [PATCH] [IMP] testjs command To tun the tests: createdb t /server/openerp-server --addons-path=addons,web/addons server -d t -i web_tests_demo Once the server is ready, run, in an other shell: /server/openerp-server --addons-path=addons,web/addons testjs bzr revid: al@openerp.com-20121209024810-cdi2s9ftr97x8l5p --- addons/web/__init__.py | 1 + addons/web/cli/__init__.py | 1 + addons/web/cli/test_js.py | 31 +++++ addons/web/tests/qunitsuite/README.rst | 116 ++++++++++++++++ addons/web/tests/qunitsuite/__init__.py | 0 .../web/tests/qunitsuite/grunt/bootstrap.js | 95 +++++++++++++ addons/web/tests/qunitsuite/grunt/license | 22 +++ .../web/tests/qunitsuite/grunt/phantomjs.json | 1 + .../grunt/qunit-phantomjs-bridge.js | 88 ++++++++++++ addons/web/tests/qunitsuite/suite.py | 131 ++++++++++++++++++ addons/web/tests/test_js.py | 5 +- 11 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 addons/web/cli/__init__.py create mode 100644 addons/web/cli/test_js.py create mode 100644 addons/web/tests/qunitsuite/README.rst create mode 100644 addons/web/tests/qunitsuite/__init__.py create mode 100644 addons/web/tests/qunitsuite/grunt/bootstrap.js create mode 100644 addons/web/tests/qunitsuite/grunt/license create mode 100644 addons/web/tests/qunitsuite/grunt/phantomjs.json create mode 100644 addons/web/tests/qunitsuite/grunt/qunit-phantomjs-bridge.js create mode 100644 addons/web/tests/qunitsuite/suite.py diff --git a/addons/web/__init__.py b/addons/web/__init__.py index 3b447ce748d..e01969ddcca 100644 --- a/addons/web/__init__.py +++ b/addons/web/__init__.py @@ -1,4 +1,5 @@ import http import controllers +import cli wsgi_postload = http.wsgi_postload diff --git a/addons/web/cli/__init__.py b/addons/web/cli/__init__.py new file mode 100644 index 00000000000..823c140bb79 --- /dev/null +++ b/addons/web/cli/__init__.py @@ -0,0 +1 @@ +import test_js diff --git a/addons/web/cli/test_js.py b/addons/web/cli/test_js.py new file mode 100644 index 00000000000..649f7115c02 --- /dev/null +++ b/addons/web/cli/test_js.py @@ -0,0 +1,31 @@ +import logging +import optparse +import sys + +import unittest2 + +import openerp +import openerp.addons.web.tests + +_logger = logging.getLogger(__name__) + +class TestJs(openerp.cli.Command): + def run(self, args): + self.parser = parser = optparse.OptionParser() + parser.add_option("-d", "--database", dest="db_name", default=False, help="specify the database name") + parser.add_option("--xmlrpc-port", dest="xmlrpc_port", default=8069, help="specify the TCP port for the XML-RPC protocol", type="int") + self.parser.parse_args(args) + + # test ony uses db_name xmlrpc_port admin_passwd, so use the server one for the actual parsing + config = openerp.tools.config + config.parse_config(args) + + # run js tests + openerp.netsvc.init_alternative_logger() + suite = unittest2.TestSuite() + suite.addTests(unittest2.TestLoader().loadTestsFromModule(openerp.addons.web.tests.test_js)) + r = unittest2.TextTestRunner(verbosity=2).run(suite) + if r.errors or r.failures: + sys.exit(1) + +# vim:et:ts=4:sw=4: diff --git a/addons/web/tests/qunitsuite/README.rst b/addons/web/tests/qunitsuite/README.rst new file mode 100644 index 00000000000..f1cfb96a508 --- /dev/null +++ b/addons/web/tests/qunitsuite/README.rst @@ -0,0 +1,116 @@ +QUnitSuite is a ``unittest.TestSuite`` able to run QUnit_ test suites +within the normal unittest process, through PhantomJS_. + +QUnitSuite is built upon `Ben Alman`_'s work of for the interfacing +between PhantomJS_ and the host/reporting code: the shims and the +PhantomJS_ configuration files are those of grunt_'s ``qunit`` task. + +Why +--- + +You're a Python shop or developer, you have tools and tests built +around unittest (or compatible with unittests) and your testing +pipeline is predicated upon that, you're doing web development of some +sort these days (as so many are) and you'd like to do some testing of +your web stuff. + +But you don't really want to redo your whole testing stack just for +that. + +QUnitSuite simply grafts QUnit_-based tests, run in PhantomJS_, in +your existing ``unittest``-based architecture. + +What +---- + +QUnitSuite currently provides a single object as part of its API: +``qunitsuite.QUnitSuite(testfile[, timeout])``. + +This produces a ``unittest.TestSuite`` suitable for all the usual +stuff (running it, and giving it to an other test suite which will run +it, that is). + +``testfile`` is the HTML file bootstrapping your qunit tests, as would +usually be accessed via a browser. It can be either a local +(``file:``) url, or an HTTP one. As long as a regular browser can open +and execute it, PhantomJS_ will manage. + +``timeout`` is a check passed to the PhantomJS_ runner: if the runner +produces no information for longer than ``timeout`` milliseconds, the +run will be cancelled and a test error will be generated. This +situation usually means either your ``testfile`` is not a qunit test +file, qunit is not running or qunit's runner was stopped (for an async +test) and never restarted. + +The default value is very conservative, most tests should run +correctly with lower timeouts (especially if all tests are +synchronous). + +How +--- + +``unittest``'s autodiscovery protocol does not directly work with test +suites (it looks for test cases). If you want autodiscovery to work +correctly, you will have to use the ``load_tests`` protocol:: + + # in a testing module + def load_tests(loader, tests, pattern): + tests.addTest(QUnitSuite(qunit_test_path.html)) + return tests + +outside of that specific case, you can use a ``QUnitSuite`` as a +standard ``TestSuite`` instance, running it, adding it to an other +suite or passing it to a ``TestRunner`` + +Complaints and Grievances +------------------------- + +Speed +~~~~~ + +Starting up a phantomjs instance and running a suite turns out to have +a rather high overhead, on the order of a second on this machine +(2.4GHz, 8GB RAM and an SSD). + +As each ``QUnitSuite`` currently creates its own phantomjs instance, +it's probably a good idea to create bigger suites (put many modules & +tests in the same QUnit html file, which doesn't preclude splitting +them across multiple js files). + +Hacks +~~~~~ + +QUnitSuite contains a pretty big hack which may or may not cause +problem depending on your exact setup: in case of case failure or +error, ``unittest.TestResult`` formats the error traceback provided +alongside the test object. This goes through Python's +traceback-formatting code and there are no hooks there. + +One could expect to use a custom ``TestResult``, but for test suites +the ``TestResult`` instance must be provided by the caller, so there +is no direct hook onto it. + +This leaves three options: + +* Create a custom ``TestResult`` class and require that it be the one + provided to the test suite. This requires altered work flows, + customization of the test runner and (as far as I know) isn't + available through Python 2.7's autodiscovery. It's the cleanest + option but completely fails on practicality. + +* Create a custom ``TestResult`` which directly alters the original + result's ``errors`` and ``failures`` attributes as they're part of + the testrunner API. This would work but may put custom results in a + strange state and break e.g. unittest2's ``@failfast``. + +* Lastly, monkeypatch the undocumented and implementation detail + ``_exc_info_to_string`` on the provided ``result``. This is the + route taken, at least for now. + +.. _QUnit: http://qunitjs.com/ + +.. _PhantomJS: http://phantomjs.org/ + +.. _Ben Alman: http://benalman.com/ + +.. _grunt: http://gruntjs.com/ diff --git a/addons/web/tests/qunitsuite/__init__.py b/addons/web/tests/qunitsuite/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/addons/web/tests/qunitsuite/grunt/bootstrap.js b/addons/web/tests/qunitsuite/grunt/bootstrap.js new file mode 100644 index 00000000000..3d5e38912ca --- /dev/null +++ b/addons/web/tests/qunitsuite/grunt/bootstrap.js @@ -0,0 +1,95 @@ +/* + * grunt + * http://gruntjs.com/ + * + * Copyright (c) 2012 "Cowboy" Ben Alman + * Licensed under the MIT license. + * http://benalman.com/about/license/ + */ + +/*global phantom:true*/ + +'use strict'; + +var fs = require('fs'); + +// The page .html file to load. +var url = phantom.args[0]; +// Extra, optionally overridable stuff. +var options = JSON.parse(phantom.args[1] || {}); + +// Default options. +if (!options.timeout) { options.timeout = 5000; } + +// Keep track of the last time a client message was sent. +var last = new Date(); + +// Messages are sent to the parent by appending them to the tempfile. +var sendMessage = function(arg) { + var args = Array.isArray(arg) ? arg : [].slice.call(arguments); + last = new Date(); + console.log(JSON.stringify(args)); +}; + +// This allows grunt to abort if the PhantomJS version isn't adequate. +sendMessage('private', 'version', phantom.version); + +// Abort if the page doesn't send any messages for a while. +setInterval(function() { + if (new Date() - last > options.timeout) { + sendMessage('fail.timeout'); + phantom.exit(); + } +}, 100); + +// Create a new page. +var page = require('webpage').create(); + +// The client page must send its messages via alert(jsonstring). +page.onAlert = function(args) { + sendMessage(JSON.parse(args)); +}; + +// Keep track if the client-side helper script already has been injected. +var injected; +page.onUrlChanged = function(newUrl) { + injected = false; + sendMessage('onUrlChanged', newUrl); +}; + +// Relay console logging messages. +page.onConsoleMessage = function(message) { + sendMessage('console', message); +}; + +// For debugging. +page.onResourceRequested = function(request) { + sendMessage('onResourceRequested', request.url); +}; + +page.onResourceReceived = function(request) { + if (request.stage === 'end') { + sendMessage('onResourceReceived', request.url); + } +}; + +// Run when the page has finished loading. +page.onLoadFinished = function(status) { + // The window has loaded. + sendMessage('onLoadFinished', status); + if (status === 'success') { + if (options.inject && !injected) { + // Inject client-side helper script, but only if it has not yet been + // injected. + sendMessage('inject', options.inject); + page.injectJs(options.inject); + } + } else { + // File loading failure. + sendMessage('fail.load', url); + phantom.exit(); + } +}; + +// Actually load url. +page.open(url); diff --git a/addons/web/tests/qunitsuite/grunt/license b/addons/web/tests/qunitsuite/grunt/license new file mode 100644 index 00000000000..90c336c39d3 --- /dev/null +++ b/addons/web/tests/qunitsuite/grunt/license @@ -0,0 +1,22 @@ +Copyright (c) 2012 "Cowboy" Ben Alman + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/addons/web/tests/qunitsuite/grunt/phantomjs.json b/addons/web/tests/qunitsuite/grunt/phantomjs.json new file mode 100644 index 00000000000..9e26dfeeb6e --- /dev/null +++ b/addons/web/tests/qunitsuite/grunt/phantomjs.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/addons/web/tests/qunitsuite/grunt/qunit-phantomjs-bridge.js b/addons/web/tests/qunitsuite/grunt/qunit-phantomjs-bridge.js new file mode 100644 index 00000000000..032fe8a3aa0 --- /dev/null +++ b/addons/web/tests/qunitsuite/grunt/qunit-phantomjs-bridge.js @@ -0,0 +1,88 @@ +/* + * grunt + * http://gruntjs.com/ + * + * Copyright (c) 2012 "Cowboy" Ben Alman + * Licensed under the MIT license. + * http://benalman.com/about/license/ + */ + +/*global QUnit:true, alert:true*/ + +'use strict'; + +// Don't re-order tests. +QUnit.config.reorder = false; +// Run tests serially, not in parallel. +QUnit.config.autorun = false; + +// Send messages to the parent PhantomJS process via alert! Good times!! +function sendMessage() { + var args = [].slice.call(arguments); + alert(JSON.stringify(args)); +} + +// These methods connect QUnit to PhantomJS. +QUnit.log(function(obj) { + // What is this I don’t even + if (obj.message === '[object Object], undefined:undefined') { return; } + // Parse some stuff before sending it. + var actual = QUnit.jsDump.parse(obj.actual); + var expected = QUnit.jsDump.parse(obj.expected); + // Send it. + sendMessage('qunit.log', obj.result, actual, expected, obj.message, obj.source); +}); + +QUnit.testStart(function(obj) { + sendMessage('qunit.testStart', obj.name); +}); + +QUnit.testDone(function(obj) { + sendMessage('qunit.testDone', obj.name, obj.failed, obj.passed, obj.total); +}); + +QUnit.moduleStart(function(obj) { + sendMessage('qunit.moduleStart', obj.name); +}); + +QUnit.moduleDone(function(obj) { + sendMessage('qunit.moduleDone', obj.name, obj.failed, obj.passed, obj.total); +}); + +QUnit.begin(function() { + sendMessage('qunit.begin'); +}); + +QUnit.done(function(obj) { + sendMessage('qunit.done', obj.failed, obj.passed, obj.total, obj.runtime); +}); + +// PhantomJS (up to and including 1.7) uses a version of webkit so old +// it does not have Function.prototype.bind: +// http://code.google.com/p/phantomjs/issues/detail?id=522 + +// Use moz polyfill: +// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind#Compatibility +if (!Function.prototype.bind) { + Function.prototype.bind = function (oThis) { + if (typeof this !== "function") { + // closest thing possible to the ECMAScript 5 internal IsCallable function + throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function () {}, + fBound = function () { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + + return fBound; + }; +} diff --git a/addons/web/tests/qunitsuite/suite.py b/addons/web/tests/qunitsuite/suite.py new file mode 100644 index 00000000000..7f0a0cb4976 --- /dev/null +++ b/addons/web/tests/qunitsuite/suite.py @@ -0,0 +1,131 @@ +import json +import subprocess +import unittest +import os +import time + +ROOT = os.path.join(os.path.dirname(__file__), 'grunt') + +__all__ = ['QUnitSuite'] + +def _exc_info_to_string(err, test): + return err + +class QUnitTest(unittest.TestCase): + def __init__(self, module, name): + self.module = module + self.name = name + self.failed = False + def shortDescription(self): + return None + def __repr__(self): + return '' % (self.module, self.name) + def __str__(self): + return '%s: %s' % (self.module, self.name) + +class QUnitSuite(unittest.TestSuite): + def __init__(self, qunitfile, timeout=5000): + self.testfile = qunitfile + self.timeout = timeout + self._module = None + self._test = None + + def __iter__(self): + return iter([self]) + + def run(self, result): + try: + subprocess.call(['phantomjs', '-v'], + stdout=open(os.devnull, 'w'), + stderr=subprocess.STDOUT) + except OSError: + test = QUnitTest('phantomjs', 'javascript tests') + result.startTest(test) + result.startTest(test) + result.addSkip(test , "phantomjs command not found") + result.stopTest(test) + return + + result._exc_info_to_string = _exc_info_to_string + try: + self._run(result) + finally: + del result._exc_info_to_string + + def _run(self, result): + phantom = subprocess.Popen([ + 'phantomjs', + '--config=%s' % os.path.join(ROOT, 'phantomjs.json'), + os.path.join(ROOT, 'bootstrap.js'), self.testfile, + json.dumps({ + 'timeout': self.timeout, + 'inject': os.path.join(ROOT, 'qunit-phantomjs-bridge.js') + }) + ], stdout=subprocess.PIPE) + + try: + while True: + line = phantom.stdout.readline() + if line: + if self.process(line, result): + break + else: + time.sleep(0.1) + finally: + # If the phantomjs process hasn't quit, kill it + if phantom.poll() is None: + phantom.terminate() + + def process(self, line, result): + args = json.loads(line) + event_name = args[0] + + if event_name == 'qunit.done': + return True + elif event_name == 'fail.load': + self.add_error(result, "PhantomJS unable to load %s" % args[1]) + return True + elif event_name == 'fail.timeout': + self.add_error(result, "PhantomJS timed out, possibly due to a" + " missing QUnit start() call") + return True + + elif event_name == 'qunit.moduleStart': + self._module = args[1].encode('utf-8') + elif event_name == 'qunit.moduleStop': + self._test = None + self._module = None + elif event_name == 'qunit.testStart': + self._test = QUnitTest(self._module, args[1].encode('utf-8')) + result.startTest(self._test) + elif event_name == 'qunit.testDone': + if not self._test.failed: + result.addSuccess(self._test) + result.stopTest(self._test) + self._test = None + elif event_name == 'qunit.log': + if args[1]: + return False + + self._test.failed = True + result.addFailure( + self._test, self.failure_to_str(*args[2:])) + + return False + + def add_error(self, result, s): + test = QUnitTest('phantomjs', 'startup') + result.startTest(test) + result.addError(test, s) + result.stopTest(test) + + def failure_to_str(self, actual, expected, message, source): + if message or actual == expected: + formatted = str(message or '') + else: + formatted = "%s != %s" % (actual, expected) + + if source: + formatted += '\n\n' + source + + return formatted diff --git a/addons/web/tests/test_js.py b/addons/web/tests/test_js.py index a126ce4b2f3..28163f22775 100644 --- a/addons/web/tests/test_js.py +++ b/addons/web/tests/test_js.py @@ -10,7 +10,10 @@ class WebSuite(QUnitSuite): '/web/tests', 'mod=*&source={db}&supadmin={supadmin}&password={password}'.format( db=tools.config['db_name'], - supadmin=tools.config['db_password'] or 'admin', + # al: i dont understand why both are needed, db_password is the + # password for postgres and should not appear here of that i'm + # sure + supadmin=tools.config['admin_passwd'] or 'admin', password=tools.config['admin_passwd'] or 'admin'), '' ])