Sfoglia il codice sorgente

HUE-3232 [Infra] run jasmine tests on build

Enrico Berti 9 anni fa
parent
commit
473196c

+ 2 - 0
.gitignore

@@ -106,3 +106,5 @@ dependency-reduced-pom.xml
 
 # Node Modules
 node_modules/
+
+junitresults*

+ 3 - 0
Makefile

@@ -278,6 +278,9 @@ ext-clean:
 # Misc (some used by automated test scripts)
 ###############################################
 
+js-test:
+	$(ROOT)/tools/jasmine/phantomjs.runner.sh $(ROOT)/desktop/core/src/desktop/templates/jasmineRunner.html
+
 java-test:
 	mvn -f desktop/libs/hadoop/java/pom.xml test $(MAVEN_OPTIONS)
 

+ 5 - 0
README.md

@@ -124,6 +124,11 @@ __MacOS:__
 * openssl (Homebrew)
 * Required for Mac OS X 10.11+ (El Capitan), after ``brew install openssl``, run: ``export LDFLAGS=-L/usr/local/opt/openssl/lib && export CPPFLAGS=-I/usr/local/opt/openssl/include``
 
+__All, just in case you want to run the Jasmine tests:__
+
+* NodeJS (https://nodejs.org/)
+* PhantomJS (npm install -g phantomjs-prebuilt)
+
 
 File Layout
 -----------

+ 379 - 0
desktop/core/src/desktop/static/desktop/ext/js/jasmine-2.3.4/junit_reporter.js

@@ -0,0 +1,379 @@
+/* global __phantom_writeFile */
+(function(global) {
+    var UNDEFINED,
+        exportObject;
+
+    if (typeof module !== "undefined" && module.exports) {
+        exportObject = exports;
+    } else {
+        exportObject = global.jasmineReporters = global.jasmineReporters || {};
+    }
+
+    function trim(str) { return str.replace(/^\s+/, "" ).replace(/\s+$/, "" ); }
+    function elapsed(start, end) { return (end - start)/1000; }
+    function isFailed(obj) { return obj.status === "failed"; }
+    function isSkipped(obj) { return obj.status === "pending"; }
+    function isDisabled(obj) { return obj.status === "disabled"; }
+    function pad(n) { return n < 10 ? '0'+n : n; }
+    function extend(dupe, obj) { // performs a shallow copy of all props of `obj` onto `dupe`
+        for (var prop in obj) {
+            if (obj.hasOwnProperty(prop)) {
+                dupe[prop] = obj[prop];
+            }
+        }
+        return dupe;
+    }
+    function ISODateString(d) {
+        return d.getFullYear() + '-' +
+            pad(d.getMonth()+1) + '-' +
+            pad(d.getDate()) + 'T' +
+            pad(d.getHours()) + ':' +
+            pad(d.getMinutes()) + ':' +
+            pad(d.getSeconds());
+    }
+    function escapeInvalidXmlChars(str) {
+        return str.replace(/\&/g, "&amp;")
+            .replace(/</g, "&lt;")
+            .replace(/\>/g, "&gt;")
+            .replace(/\"/g, "&quot;")
+            .replace(/\'/g, "&apos;");
+    }
+    function getQualifiedFilename(path, filename, separator) {
+        if (path && path.substr(-1) !== separator && filename.substr(0) !== separator) {
+            path += separator;
+        }
+        return path + filename;
+    }
+    function log(str) {
+        var con = global.console || console;
+        if (con && con.log) {
+            con.log(str);
+        }
+    }
+
+    /**
+     * A delegate for letting the consumer
+     * modify the suite name when it is used inside the junit report and as a file
+     * name. This is useful when running a test suite against multiple capabilities
+     * because the report can have unique names for each combination of suite/spec
+     * and capability/test environment.
+     *
+     * @callback modifySuiteName
+     * @param {string} fullName
+     * @param {object} suite
+     */
+
+    /**
+     * Generates JUnit XML for the given spec run. There are various options
+     * to control where the results are written, and the default values are
+     * set to create as few .xml files as possible. It is possible to save a
+     * single XML file, or an XML file for each top-level `describe`, or an
+     * XML file for each `describe` regardless of nesting.
+     *
+     * Usage:
+     *
+     * jasmine.getEnv().addReporter(new jasmineReporters.JUnitXmlReporter(options));
+     *
+     * @param {object} [options]
+     * @param {string} [savePath] directory to save the files (default: '')
+     * @param {boolean} [consolidateAll] whether to save all test results in a
+     *   single file (default: true)
+     *   NOTE: if true, {filePrefix} is treated as the full filename (excluding
+     *     extension)
+     * @param {boolean} [consolidate] whether to save nested describes within the
+     *   same file as their parent (default: true)
+     *   NOTE: true does nothing if consolidateAll is also true.
+     *   NOTE: false also sets consolidateAll to false.
+     * @param {boolean} [useDotNotation] whether to separate suite names with
+     *   dots instead of spaces, ie "Class.init" not "Class init" (default: true)
+     * @param {string} [filePrefix] is the string value that is prepended to the
+     *   xml output file (default: junitresults-)
+     *   NOTE: if consolidateAll is true, the default is simply "junitresults" and
+     *     this becomes the actual filename, ie "junitresults.xml"
+     * @param {string} [package] is the base package for all test suits that are
+     *   handled by this report {default: none}
+     * @param {function} [modifySuiteName] a delegate for letting the consumer
+     *   modify the suite name when it is used inside the junit report and as a file
+     *   name. This is useful when running a test suite against multiple capabilities
+     *   because the report can have unique names for each combination of suite/spec
+     *   and capability/test environment.
+     * @param {function} [systemOut] a delegate for letting the consumer add content
+     *   to a <system-out> tag as part of each <testcase> spec output. If provided,
+     *   it is invoked with the spec object and the fully qualified suite as filename.
+     */
+    exportObject.JUnitXmlReporter = function(options) {
+        var self = this;
+        self.started = false;
+        self.finished = false;
+        // sanitize arguments
+        options = options || {};
+        self.savePath = options.savePath || '';
+        self.consolidate = options.consolidate === UNDEFINED ? true : options.consolidate;
+        self.consolidateAll = self.consolidate !== false && (options.consolidateAll === UNDEFINED ? true : options.consolidateAll);
+        self.useDotNotation = options.useDotNotation === UNDEFINED ? true : options.useDotNotation;
+        if (self.consolidateAll) {
+            self.filePrefix = options.filePrefix || 'junitresults';
+        } else {
+            self.filePrefix = typeof options.filePrefix === 'string' ? options.filePrefix : 'junitresults-';
+        }
+        self.package = typeof(options.package) === 'string' ? escapeInvalidXmlChars(options.package) : UNDEFINED;
+
+        if(options.modifySuiteName && typeof options.modifySuiteName !== 'function') {
+            throw new Error('option "modifySuiteName" must be a function');
+        }
+        if(options.systemOut && typeof options.systemOut !== 'function') {
+            throw new Error('option "systemOut" must be a function');
+        }
+
+        var delegates = {};
+        delegates.modifySuiteName = options.modifySuiteName;
+        delegates.systemOut = options.systemOut;
+
+        var suites = [],
+            currentSuite = null,
+            totalSpecsExecuted = 0,
+            totalSpecsDefined,
+            // when use use fit, jasmine never calls suiteStarted / suiteDone, so make a fake one to use
+            fakeFocusedSuite = {
+                id: 'focused',
+                description: 'focused specs',
+                fullName: 'focused specs'
+            };
+
+        var __suites = {}, __specs = {};
+        function getSuite(suite) {
+            __suites[suite.id] = extend(__suites[suite.id] || {}, suite);
+            return __suites[suite.id];
+        }
+        function getSpec(spec) {
+            __specs[spec.id] = extend(__specs[spec.id] || {}, spec);
+            return __specs[spec.id];
+        }
+
+        self.jasmineStarted = function(summary) {
+            totalSpecsDefined = summary && summary.totalSpecsDefined || NaN;
+            exportObject.startTime = new Date();
+            self.started = true;
+        };
+        self.suiteStarted = function(suite) {
+            suite = getSuite(suite);
+            suite._startTime = new Date();
+            suite._specs = [];
+            suite._suites = [];
+            suite._failures = 0;
+            suite._skipped = 0;
+            suite._disabled = 0;
+            suite._parent = currentSuite;
+            if (!currentSuite) {
+                suites.push(suite);
+            } else {
+                currentSuite._suites.push(suite);
+            }
+            currentSuite = suite;
+        };
+        self.specStarted = function(spec) {
+            if (!currentSuite) {
+                // focused spec (fit) -- suiteStarted was never called
+                self.suiteStarted(fakeFocusedSuite);
+            }
+            spec = getSpec(spec);
+            spec._startTime = new Date();
+            spec._suite = currentSuite;
+            currentSuite._specs.push(spec);
+        };
+        self.specDone = function(spec) {
+            spec = getSpec(spec);
+            spec._endTime = new Date();
+            if (isSkipped(spec)) { spec._suite._skipped++; }
+            if (isDisabled(spec)) { spec._suite._disabled++; }
+            if (isFailed(spec)) { spec._suite._failures += spec.failedExpectations.length; }
+            totalSpecsExecuted++;
+        };
+        self.suiteDone = function(suite) {
+            suite = getSuite(suite);
+            if (suite._parent === UNDEFINED) {
+                // disabled suite (xdescribe) -- suiteStarted was never called
+                self.suiteStarted(suite);
+            }
+            suite._endTime = new Date();
+            currentSuite = suite._parent;
+        };
+        self.jasmineDone = function() {
+            if (currentSuite) {
+                // focused spec (fit) -- suiteDone was never called
+                self.suiteDone(fakeFocusedSuite);
+            }
+            var output = '';
+            for (var i = 0; i < suites.length; i++) {
+                output += self.getOrWriteNestedOutput(suites[i]);
+            }
+            // if we have anything to write here, write out the consolidated file
+            if (output) {
+                wrapOutputAndWriteFile(self.filePrefix, output);
+            }
+            //log("Specs skipped but not reported (entire suite skipped or targeted to specific specs)", totalSpecsDefined - totalSpecsExecuted + totalSpecsDisabled);
+
+            self.finished = true;
+            // this is so phantomjs-testrunner.js can tell if we're done executing
+            exportObject.endTime = new Date();
+        };
+
+        self.getOrWriteNestedOutput = function(suite) {
+            var output = suiteAsXml(suite);
+            for (var i = 0; i < suite._suites.length; i++) {
+                output += self.getOrWriteNestedOutput(suite._suites[i]);
+            }
+            if (self.consolidateAll || self.consolidate && suite._parent) {
+                return output;
+            } else {
+                // if we aren't supposed to consolidate output, just write it now
+                wrapOutputAndWriteFile(generateFilename(suite), output);
+                return '';
+            }
+        };
+
+        self.writeFile = function(filename, text) {
+            var errors = [];
+            var path = self.savePath;
+
+            function phantomWrite(path, filename, text) {
+                // turn filename into a qualified path
+                filename = getQualifiedFilename(path, filename, window.fs_path_separator);
+                // write via a method injected by phantomjs-testrunner.js
+                __phantom_writeFile(filename, text);
+            }
+
+            function nodeWrite(path, filename, text) {
+                var fs = require("fs");
+                var nodejs_path = require("path");
+                require("mkdirp").sync(path); // make sure the path exists
+                var filepath = nodejs_path.join(path, filename);
+                var xmlfile = fs.openSync(filepath, "w");
+                fs.writeSync(xmlfile, text, 0);
+                fs.closeSync(xmlfile);
+                return;
+            }
+            // Attempt writing with each possible environment.
+            // Track errors in case no write succeeds
+            try {
+                phantomWrite(path, filename, text);
+                return;
+            } catch (e) { errors.push('  PhantomJs attempt: ' + e.message); }
+            try {
+                nodeWrite(path, filename, text);
+                return;
+            } catch (f) { errors.push('  NodeJS attempt: ' + f.message); }
+
+            // If made it here, no write succeeded.  Let user know.
+            log("Warning: writing junit report failed for '" + path + "', '" +
+                filename + "'. Reasons:\n" +
+                errors.join("\n")
+            );
+        };
+
+        /******** Helper functions with closure access for simplicity ********/
+        function generateFilename(suite) {
+            return self.filePrefix + getFullyQualifiedSuiteName(suite, true) + '.xml';
+        }
+
+        function getFullyQualifiedSuiteName(suite, isFilename) {
+            var fullName;
+            if (self.useDotNotation || isFilename) {
+                fullName = suite.description;
+                for (var parent = suite._parent; parent; parent = parent._parent) {
+                    fullName = parent.description + '.' + fullName;
+                }
+            } else {
+                fullName = suite.fullName;
+            }
+
+            // Either remove or escape invalid XML characters
+            if (isFilename) {
+                var fileName = "",
+                    rFileChars = /[\w\.]/,
+                    chr;
+                while (fullName.length) {
+                    chr = fullName[0];
+                    fullName = fullName.substr(1);
+                    if (rFileChars.test(chr)) {
+                        fileName += chr;
+                    }
+                }
+                return fileName;
+            } else {
+
+                if(delegates.modifySuiteName) {
+                    fullName = options.modifySuiteName(fullName, suite);
+                }
+
+                return escapeInvalidXmlChars(fullName);
+            }
+        }
+
+        function suiteAsXml(suite) {
+            var xml = '\n <testsuite name="' + getFullyQualifiedSuiteName(suite) + '"';
+            xml += ' timestamp="' + ISODateString(suite._startTime) + '"';
+            xml += ' hostname="localhost"'; // many CI systems like Jenkins don't care about this, but junit spec says it is required
+            xml += ' time="' + elapsed(suite._startTime, suite._endTime) + '"';
+            xml += ' errors="0"';
+            xml += ' tests="' + suite._specs.length + '"';
+            xml += ' skipped="' + suite._skipped + '"';
+            xml += ' disabled="' + suite._disabled + '"';
+            // Because of JUnit's flat structure, only include directly failed tests (not failures for nested suites)
+            xml += ' failures="' + suite._failures + '"';
+            if (self.package) {
+                xml += ' package="' + self.package + '"';
+            }
+            xml += '>';
+
+            for (var i = 0; i < suite._specs.length; i++) {
+                xml += specAsXml(suite._specs[i]);
+            }
+            xml += '\n </testsuite>';
+            return xml;
+        }
+        function specAsXml(spec) {
+            var xml = '\n  <testcase classname="' + getFullyQualifiedSuiteName(spec._suite) + '"';
+            xml += ' name="' + escapeInvalidXmlChars(spec.description) + '"';
+            xml += ' time="' + elapsed(spec._startTime, spec._endTime) + '"';
+
+            var testCaseBody = '';
+            if (isSkipped(spec) || isDisabled(spec)) {
+                if (spec.pendingReason) {
+                    testCaseBody = '\n   <skipped message="' + trim(escapeInvalidXmlChars(spec.pendingReason)) + '" />';
+                } else {
+                    testCaseBody = '\n   <skipped />';
+                }
+            } else if (isFailed(spec)) {
+                for (var i = 0, failure; i < spec.failedExpectations.length; i++) {
+                    failure = spec.failedExpectations[i];
+                    testCaseBody += '\n   <failure type="' + (failure.matcherName || "exception") + '"';
+                    testCaseBody += ' message="' + trim(escapeInvalidXmlChars(failure.message))+ '"';
+                    testCaseBody += '>';
+                    testCaseBody += '<![CDATA[' + trim(failure.stack || failure.message) + ']]>';
+                    testCaseBody += '\n   </failure>';
+                }
+            }
+
+            if (testCaseBody || delegates.systemOut) {
+                xml += '>' + testCaseBody;
+                if (delegates.systemOut) {
+                    xml += '\n   <system-out>' + trim(escapeInvalidXmlChars(delegates.systemOut(spec, getFullyQualifiedSuiteName(spec._suite, true)))) + '</system-out>';
+                }
+                xml += '\n  </testcase>';
+            } else {
+                xml += ' />';
+            }
+            return xml;
+        }
+
+        // To remove complexity and be more DRY about the silly preamble and <testsuites> element
+        var prefix = '<?xml version="1.0" encoding="UTF-8" ?>';
+        prefix += '\n<testsuites>';
+        var suffix = '\n</testsuites>';
+        function wrapOutputAndWriteFile(filename, text) {
+            if (filename.substr(-4) !== '.xml') { filename += '.xml'; }
+            self.writeFile(filename, (prefix + text + suffix));
+        }
+    };
+})(this);

+ 266 - 0
desktop/core/src/desktop/static/desktop/ext/js/jasmine-2.3.4/terminal_reporter.js

@@ -0,0 +1,266 @@
+(function(global) {
+    var UNDEFINED,
+        exportObject;
+
+    if (typeof module !== "undefined" && module.exports) {
+        exportObject = exports;
+    } else {
+        exportObject = global.jasmineReporters = global.jasmineReporters || {};
+    }
+
+    function elapsed(start, end) { return (end - start)/1000; }
+    function isFailed(obj) { return obj.status === "failed"; }
+    function isSkipped(obj) { return obj.status === "pending"; }
+    function isDisabled(obj) { return obj.status === "disabled"; }
+    function extend(dupe, obj) { // performs a shallow copy of all props of `obj` onto `dupe`
+        for (var prop in obj) {
+            if (obj.hasOwnProperty(prop)) {
+                dupe[prop] = obj[prop];
+            }
+        }
+        return dupe;
+    }
+    function log(str) {
+        var con = global.console || console;
+        if (con && con.log && str && str.length) {
+            con.log(str);
+        }
+    }
+
+
+    /**
+     * Basic reporter that outputs spec results to the terminal.
+     * Use this reporter in your build pipeline.
+     *
+     * Usage:
+     *
+     * jasmine.getEnv().addReporter(new jasmineReporters.TerminalReporter(options);
+     *
+     * @param {object} [options]
+     * @param {number} [options.verbosity] meaningful values are 0 through 3; anything
+     *   greater than 3 is treated as 3 (default: 2)
+     * @param {boolean} [options.color] print in color or not (default: false)
+     * @param {boolean} [options.showStack] show stack trace for failed specs (default: false)
+     */
+    var DEFAULT_VERBOSITY = 2,
+        ATTRIBUTES_TO_ANSI = {
+            "off": 0,
+            "bold": 1,
+            "red": 31,
+            "green": 32,
+            "yellow": 33,
+            "blue": 34,
+            "magenta": 35,
+            "cyan": 36
+        };
+
+    exportObject.TerminalReporter = function(options) {
+        var self = this;
+        self.started = false;
+        self.finished = false;
+
+        // sanitize arguments
+        options = options || {};
+        self.verbosity = typeof options.verbosity === "number" ? options.verbosity : DEFAULT_VERBOSITY;
+        self.color = options.color;
+        self.showStack = options.showStack;
+
+        var indent_string = '  ',
+            startTime,
+            currentSuite = null,
+            totalSpecsExecuted = 0,
+            totalSpecsSkipped = 0,
+            totalSpecsDisabled = 0,
+            totalSpecsFailed = 0,
+            totalSpecsDefined,
+            // when use use fit, jasmine never calls suiteStarted / suiteDone, so make a fake one to use
+            fakeFocusedSuite = {
+                id: 'focused',
+                description: 'focused specs',
+                fullName: 'focused specs'
+            };
+
+        var __suites = {}, __specs = {};
+        function getSuite(suite) {
+            __suites[suite.id] = extend(__suites[suite.id] || {}, suite);
+            return __suites[suite.id];
+        }
+        function getSpec(spec) {
+            __specs[spec.id] = extend(__specs[spec.id] || {}, spec);
+            return __specs[spec.id];
+        }
+
+        self.jasmineStarted = function(summary) {
+            totalSpecsDefined = summary && summary.totalSpecsDefined || NaN;
+            startTime = exportObject.startTime = new Date();
+            self.started = true;
+        };
+        self.suiteStarted = function(suite) {
+            suite = getSuite(suite);
+            suite._specs = 0;
+            suite._nestedSpecs = 0;
+            suite._failures = 0;
+            suite._nestedFailures = 0;
+            suite._skipped = 0;
+            suite._nestedSkipped = 0;
+            suite._disabled = 0;
+            suite._nestedDisabled = 0;
+            suite._depth = currentSuite ? currentSuite._depth+1 : 1;
+            suite._parent = currentSuite;
+            currentSuite = suite;
+            if (self.verbosity > 2) {
+                log(indentWithLevel(suite._depth, inColor(suite.description, "bold")));
+            }
+        };
+        self.specStarted = function(spec) {
+            if (!currentSuite) {
+                // focused spec (fit) -- suiteStarted was never called
+                self.suiteStarted(fakeFocusedSuite);
+            }
+            spec = getSpec(spec);
+            spec._suite = currentSuite;
+            spec._depth = currentSuite._depth+1;
+            currentSuite._specs++;
+            if (self.verbosity > 2) {
+                log(indentWithLevel(spec._depth, spec.description + ' ...'));
+            }
+        };
+        self.specDone = function(spec) {
+            spec = getSpec(spec);
+            var failed = false,
+                skipped = false,
+                disabled = false,
+                color = 'green',
+                resultText = '';
+            if (isSkipped(spec)) {
+                skipped = true;
+                color = '';
+                spec._suite._skipped++;
+                totalSpecsSkipped++;
+            }
+            if (isFailed(spec)) {
+                failed = true;
+                color = 'red';
+                spec._suite._failures++;
+                totalSpecsFailed++;
+            }
+            if (isDisabled(spec)) {
+                disabled = true;
+                color = 'yellow';
+                spec._suite._disabled++;
+                totalSpecsDisabled++;
+            }
+            totalSpecsExecuted++;
+
+            if (self.verbosity === 2) {
+                resultText = failed ? 'F' : skipped ? 'S' : disabled ? 'D' : '.';
+            } else if (self.verbosity > 2) {
+                resultText = ' ' + (failed ? 'Failed' : skipped ? 'Skipped' : disabled ? 'Disabled' : 'Passed');
+            }
+            log(inColor(resultText, color));
+
+            if (failed) {
+                if (self.verbosity === 1) {
+                    log(spec.fullName);
+                } else if (self.verbosity === 2) {
+                    log(' ');
+                    log(indentWithLevel(spec._depth, spec.fullName));
+                }
+
+                for (var i = 0; i < spec.failedExpectations.length; i++) {
+                    log(inColor(indentWithLevel(spec._depth, indent_string + spec.failedExpectations[i].message), color));
+                    if (self.showStack){
+                        logStackLines(spec._depth, spec.failedExpectations[i].stack.split('\n'));
+                    }
+                }
+            }
+        };
+        self.suiteDone = function(suite) {
+            suite = getSuite(suite);
+            if (suite._parent === UNDEFINED) {
+                // disabled suite (xdescribe) -- suiteStarted was never called
+                self.suiteStarted(suite);
+            }
+            if (suite._parent) {
+                suite._parent._specs += suite._specs + suite._nestedSpecs;
+                suite._parent._failures += suite._failures + suite._nestedFailures;
+                suite._parent._skipped += suite._skipped + suite._nestedSkipped;
+                suite._parent._disabled += suite._disabled + suite._nestedDisabled;
+
+            }
+            currentSuite = suite._parent;
+            if (self.verbosity < 3) {
+                return;
+            }
+
+            var total = suite._specs + suite._nestedSpecs,
+                failed = suite._failures + suite._nestedFailures,
+                skipped = suite._skipped + suite._nestedSkipped,
+                disabled = suite._disabled + suite._nestedDisabled,
+                passed = total - failed - skipped,
+                color = failed ? 'red+bold' : 'green+bold',
+                str = passed + ' of ' + total + ' passed (' + skipped + ' skipped, ' + disabled + ' disabled)';
+            log(indentWithLevel(suite._depth, inColor(str+'.', color)));
+        };
+        self.jasmineDone = function() {
+            if (currentSuite) {
+                // focused spec (fit) -- suiteDone was never called
+                self.suiteDone(fakeFocusedSuite);
+            }
+            var now = new Date(),
+                dur = elapsed(startTime, now),
+                total = totalSpecsDefined || totalSpecsExecuted,
+                disabled = total - totalSpecsExecuted + totalSpecsDisabled,
+                skipped = totalSpecsSkipped,
+                spec_str = total + (total === 1 ? " spec, " : " specs, "),
+                fail_str = totalSpecsFailed + (totalSpecsFailed === 1 ? " failure, " : " failures, "),
+                skip_str = skipped + " skipped, ",
+                disabled_str = disabled + " disabled in ",
+                summary_str = spec_str + fail_str + skip_str + disabled_str + dur + "s.",
+                result_str = (totalSpecsFailed && "FAILURE: " || "SUCCESS: ") + summary_str,
+                result_color = totalSpecsFailed && "red+bold" || "green+bold";
+
+            if (self.verbosity === 2) {
+                log('');
+            }
+
+            if (self.verbosity > 0) {
+                log(inColor(result_str, result_color));
+            }
+            //log("Specs skipped but not reported (entire suite skipped or targeted to specific specs)", totalSpecsDefined - totalSpecsExecuted + totalSpecsDisabled);
+
+            self.finished = true;
+            // this is so phantomjs-testrunner.js can tell if we're done executing
+            exportObject.endTime = now;
+        };
+        function indentWithLevel(level, string) {
+            if (!string || !string.length) {
+                return "";
+            }
+            return new Array(level).join(indent_string) + string;
+        }
+        function logStackLines(depth, lines) {
+            lines.forEach(function(line){
+                log(inColor(indentWithLevel(depth, indent_string + line), 'magenta'));
+            });
+        }
+        function inColor(string, color) {
+            var color_attributes = color && color.split("+"),
+                ansi_string = "",
+                i;
+
+            if (!string || !string.length) {
+                return "";
+            } else if (!self.color || !color_attributes) {
+                return string;
+            }
+
+            for(i = 0; i < color_attributes.length; i++) {
+                ansi_string += "\033[" + ATTRIBUTES_TO_ANSI[color_attributes[i]] + "m";
+            }
+            ansi_string += string + "\033[" + ATTRIBUTES_TO_ANSI["off"] + "m";
+
+            return ansi_string;
+        }
+    };
+})(this);

+ 1 - 16
desktop/core/src/desktop/templates/jasmine.mako

@@ -15,20 +15,5 @@
 ## limitations under the License.
 ##
 
-<%inherit file="common_jasmine.mako"/>
-
-<%block name="specs">
-  <script type="text/javascript" charset="utf-8">
-  require(['jasmine-boot', 'jasmine', 'mock-ajax'], function () {
-    // Add specs below
-    require([
-      'desktop/spec/sqlAutocompleterSpec',
-      'desktop/spec/hdfsAutocompleterSpec',
-      'desktop/spec/apiHelperSpec'
-    ], function() {
-      window.onload();
-    });
-  });
-  </script>
-</%block>
+<%include file="jasmineRunner.html"/>
 

+ 115 - 0
desktop/core/src/desktop/templates/jasmineRunner.html

@@ -0,0 +1,115 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+        "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+<head>
+  <title>Jasmine Spec Runner</title>
+
+  <link rel="shortcut icon" type="image/png" href="../static/desktop/ext/js/jasmine-2.3.4/jasmine_favicon.png">
+  <link rel="stylesheet" href="../static/desktop/ext/js/jasmine-2.3.4/jasmine.css">
+
+  <script type="text/javascript" src="../static/desktop/ext/js/jquery/jquery-2.1.1.min.js"></script>
+  <script type="text/javascript" src="../static/desktop/js/jquery.migration.js"></script>
+  <script type="text/javascript" src="../static/desktop/js/hue.utils.js"></script>
+  <script type="text/javascript" src="../static/desktop/ext/js/jquery/plugins/jquery.total-storage.min.js"></script>
+
+  <script src="../static/desktop/js/ace/ace.js"></script>
+  <script src="../static/desktop/js/ace/ext-language_tools.js"></script>
+  <script src="../static/desktop/js/ace.extended.js"></script>
+
+
+  <script src="../static/desktop/ext/js/require.js"></script>
+  <script>
+    define('jquery', [], function() {
+      return jQuery;
+    });
+    require.config({
+      urlArgs: "bust=" + (new Date()).getTime(),
+      baseUrl: "../static/",
+      paths: {
+        "jquery.ui.sortable": "desktop/ext/js/jquery/plugins/jquery-ui-1.10.4.draggable-droppable-sortable.min",
+        "knockout": "desktop/ext/js/knockout.min",
+        "ko.charts" : "desktop/js/ko.charts",
+        "knockout-mapping" : "desktop/ext/js/knockout-mapping.min",
+        "knockout-sortable" : "desktop/ext/js/knockout-sortable.min",
+        "ko.editable" : "desktop/js/ko.editable",
+        "ko.switch-case" : "desktop/js/ko.switch-case",
+        "ko.hue-bindings" : "desktop/js/ko.hue-bindings"
+      },
+      shim: {
+        "knockout": { exports: "ko" },
+        "knockout-mapping": { deps: ["knockout"] },
+        "knockout-sortable": { deps: ["knockout", "jquery", "jquery.ui.sortable"] },
+        "ko.editable": { deps: ["knockout"] },
+        "ko.switch-case": { deps: ["knockout"] },
+        "ace.extended": { deps: ["ace"] },
+        "ace.ext-language-tools": { deps: ["ace"] }
+      },
+      deps: ["knockout", "knockout-mapping"],
+      callback: function(ko, mapping) {
+        ko.mapping = mapping;
+        window.hueDebug = {
+          ko: ko,
+          viewModel: function () {
+            return ko.dataFor(document.body);
+          }
+        }
+      }
+    });
+  </script>
+
+
+  <script type="text/javascript" charset="utf-8">
+    // Adds the jasmine dependencies to the existing require config.
+    require.config({
+      urlArgs: "random=" + Math.random(),
+      baseUrl: "../static/",
+      paths: {
+        'jasmine': 'desktop/ext/js/jasmine-2.3.4/jasmine',
+        'jasmine-html': 'desktop/ext/js/jasmine-2.3.4/jasmine-html',
+        'jasmine-boot': 'desktop/ext/js/jasmine-2.3.4/boot',
+        'jasmine-junit': 'desktop/ext/js/jasmine-2.3.4/junit_reporter',
+        'jasmine-terminal': 'desktop/ext/js/jasmine-2.3.4/terminal_reporter',
+        'mock-ajax':  'desktop/ext/js/jasmine-2.3.4/mock-ajax'
+      },
+      shim: {
+        'jasmine-html': {
+          deps: ['jasmine']
+        },
+        'jasmine-boot': {
+          deps: ['jasmine', 'jasmine-html', 'jasmine-junit', 'jasmine-terminal']
+        },
+        'mock-ajax': {
+          deps: ['jasmine-boot']
+        }
+      }
+    })
+  </script>
+
+
+  <script type="text/javascript" charset="utf-8">
+    require(['jasmine-boot', 'jasmine', 'mock-ajax'], function () {
+      // Add specs below
+      require([
+        'desktop/spec/sqlAutocompleterSpec',
+        'desktop/spec/hdfsAutocompleterSpec',
+        'desktop/spec/apiHelperSpec'
+      ], function () {
+        if (/PhantomJS/.test(window.navigator.userAgent)) {
+          console.log("Running Jasmine tests with JUnitXmlReporter and TerminalReporter");
+          jasmine.getEnv().addReporter(new jasmineReporters.JUnitXmlReporter({
+            savePath: '../../',
+            consolidateAll: false
+          }));
+          jasmine.getEnv().addReporter(new jasmineReporters.TerminalReporter());
+        }
+        window.onload();
+      });
+    });
+  </script>
+
+</head>
+
+<body>
+
+</body>
+</html>

+ 229 - 0
tools/jasmine/phantomjs-testrunner.js

@@ -0,0 +1,229 @@
+/* globals jasmineRequire, phantom */
+// Verify arguments
+var system = require('system');
+var args;
+
+if(phantom.args) {
+    args = phantom.args;
+} else {
+    args = system.args.slice(1);//use system args for phantom 2.0+
+}
+
+if (args.length === 0) {
+    console.log("Simple JasmineBDD test runner for phantom.js");
+    console.log("Usage: phantomjs-testrunner.js url_to_runner.html");
+    console.log("Accepts http:// and file:// urls");
+    console.log("");
+    console.log("NOTE: This script depends on jasmine.HtmlReporter being used\non the page, for the DOM elements it creates.\n");
+    phantom.exit(2);
+}
+else {
+    var fs = require("fs"),
+        pages = [],
+        page, address, resultsKey, i, l;
+
+
+    var setupPageFn = function(p, k) {
+        return function() {
+            setupWriteFileFunction(p, k, fs.separator);
+        };
+    };
+
+    for (i = 0, l = args.length; i < l; i++) {
+        address = args[i];
+        console.log("Loading " + address);
+
+        // if provided a url without a protocol, try to use file://
+        address = address.indexOf("://") === -1 ? "file://" + address : address;
+
+        // create a WebPage object to work with
+        page = require("webpage").create();
+        page.url = address;
+
+        // When initialized, inject the reporting functions before the page is loaded
+        // (and thus before it will try to utilize the functions)
+        resultsKey = "__jr" + Math.ceil(Math.random() * 1000000);
+        page.onInitialized = setupPageFn(page, resultsKey);
+        page.open(address, processPage(null, page, resultsKey));
+        pages.push(page);
+
+        page.onConsoleMessage = logAndWorkAroundDefaultLineBreaking;
+    }
+
+    // bail when all pages have been processed
+    setInterval(function(){
+        var exit_code = 0;
+        for (i = 0, l = pages.length; i < l; i++) {
+            page = pages[i];
+            if (page.__exit_code === null) {
+                // wait until later
+                return;
+            }
+            exit_code |= page.__exit_code;
+        }
+        phantom.exit(exit_code);
+    }, 100);
+}
+
+// Thanks to hoisting, these helpers are still available when needed above
+/**
+ * Logs a message. Does not add a line-break for single characters '.' and 'F' or lines ending in ' ...'
+ *
+ * @param msg
+ */
+function logAndWorkAroundDefaultLineBreaking(msg) {
+    var interpretAsWithoutNewline = /(^(\033\[\d+m)*[\.F](\033\[\d+m)*$)|( \.\.\.$)/;
+    if (navigator.userAgent.indexOf("Windows") < 0 && interpretAsWithoutNewline.test(msg)) {
+        try {
+            system.stdout.write(msg);
+        } catch (e) {
+            var fs = require('fs');
+            fs.write('/dev/stdout', msg, 'w');
+        }
+    } else {
+        console.log(msg);
+    }
+}
+
+/**
+ * Stringifies the function, replacing any %placeholders% with mapped values.
+ *
+ * @param {function} fn The function to replace occurrences within.
+ * @param {object} replacements Key => Value object of string replacements.
+ */
+function replaceFunctionPlaceholders(fn, replacements) {
+    if (replacements && typeof replacements === "object") {
+        fn = fn.toString();
+        for (var p in replacements) {
+            if (replacements.hasOwnProperty(p)) {
+                var match = new RegExp("%" + p + "%", "g");
+                do {
+                    fn = fn.replace(match, replacements[p]);
+                } while(fn.indexOf(match) !== -1);
+            }
+        }
+    }
+    return fn;
+}
+
+/**
+ * Custom "evaluate" method which we can easily do substitution with.
+ *
+ * @param {phantomjs.WebPage} page The WebPage object to overload
+ * @param {function} fn The function to replace occurrences within.
+ * @param {object} replacements Key => Value object of string replacements.
+ */
+function evaluate(page, fn, replacements) {
+    return page.evaluate(replaceFunctionPlaceholders(fn, replacements));
+}
+
+/** Stubs a fake writeFile function into the test runner.
+ *
+ * @param {phantomjs.WebPage} page The WebPage object to inject functions into.
+ * @param {string} key The name of the global object in which file data should
+ *                     be stored for later retrieval.
+ */
+// TODO: not bothering with error checking for now (closed environment)
+function setupWriteFileFunction(page, key, path_separator) {
+    evaluate(page, function(){
+        window["%resultsObj%"] = {};
+        window.fs_path_separator = "%fs_path_separator%";
+        window.__phantom_writeFile = function(filename, text) {
+            window["%resultsObj%"][filename] = text;
+        };
+    }, {resultsObj: key, fs_path_separator: path_separator.replace("\\", "\\\\")});
+}
+
+/**
+ * Returns the loaded page's filename => output object.
+ *
+ * @param {phantomjs.WebPage} page The WebPage object to retrieve data from.
+ * @param {string} key The name of the global object to be returned. Should
+ *                     be the same key provided to setupWriteFileFunction.
+ */
+function getXmlResults(page, key) {
+    return evaluate(page, function(){
+        return window["%resultsObj%"] || {};
+    }, {resultsObj: key});
+}
+
+/**
+ * Processes a page.
+ *
+ * @param {string} status The status from opening the page via WebPage#open.
+ * @param {phantomjs.WebPage} page The WebPage to be processed.
+ */
+function processPage(status, page, resultsKey) {
+    if (status === null && page) {
+        page.__exit_code = null;
+        return function(stat){
+            processPage(stat, page, resultsKey);
+        };
+    }
+    if (status !== "success") {
+        console.error("Unable to load resource: " + address);
+        page.__exit_code = 2;
+    }
+    else {
+        var isFinished = function() {
+            return evaluate(page, function(){
+                // if there's a JUnitXmlReporter, return a boolean indicating if it is finished
+                if (window.jasmineReporters && window.jasmineReporters.startTime) {
+                    return !!window.jasmineReporters.endTime;
+                }
+                // otherwise, scrape the DOM for the HtmlReporter "finished in ..." output
+                var durElem = document.querySelector(".html-reporter .duration");
+                if (!durElem) {
+                    durElem = document.querySelector(".jasmine_html-reporter .duration");
+                }
+                return durElem && durElem.textContent && durElem.textContent.toLowerCase().indexOf("finished in") === 0;
+            });
+        };
+        var getResultsFromHtmlRunner = function() {
+            return evaluate(page, function(){
+                var resultElem = document.querySelector(".html-reporter .alert .bar");
+                if (!resultElem) {
+                    resultElem = document.querySelector(".jasmine_html-reporter .alert .bar");
+                }
+                return resultElem && resultElem.textContent &&
+                    resultElem.textContent.match(/(\d+) spec.* (\d+) failure.*/) ||
+                   ["Unable to determine success or failure."];
+            });
+        };
+        var timeout = 60000;
+        var loopInterval = 100;
+        var ival = setInterval(function(){
+            if (isFinished()) {
+                // get the results that need to be written to disk
+                var fs = require("fs"),
+                    xml_results = getXmlResults(page, resultsKey),
+                    output;
+                for (var filename in xml_results) {
+                    if (xml_results.hasOwnProperty(filename) && (output = xml_results[filename]) && typeof(output) === "string") {
+                        fs.write(filename, output, "w");
+                    }
+                }
+
+                // print out a success / failure message of the results
+                var results = getResultsFromHtmlRunner();
+                var failures = Number(results[2]);
+                if (failures > 0) {
+                    page.__exit_code = 1;
+                    clearInterval(ival);
+                }
+                else {
+                    page.__exit_code = 0;
+                    clearInterval(ival);
+                }
+            }
+            else {
+                timeout -= loopInterval;
+                if (timeout <= 0) {
+                    console.log('Page has timed out; aborting.');
+                    page.__exit_code = 2;
+                    clearInterval(ival);
+                }
+            }
+        }, loopInterval);
+    }
+}

+ 46 - 0
tools/jasmine/phantomjs.runner.sh

@@ -0,0 +1,46 @@
+#!/bin/bash
+
+# sanity check to make sure node and phantomjs exist in the PATH
+hash /usr/bin/env node &> /dev/null
+if [ $? -eq 1 ]; then
+    echo "ERROR: node is not installed"
+    echo "Please visit http://www.nodejs.org/"
+    exit 1
+fi
+hash /usr/bin/env phantomjs &> /dev/null
+if [ $? -eq 1 ]; then
+    echo "ERROR: phantomjs is not installed"
+    echo "Please visit http://www.phantomjs.org/"
+    exit 1
+fi
+
+# sanity check number of args
+if [ $# -lt 1 ]
+then
+    echo "Usage: `basename $0` path_to_runner.html"
+    echo
+    exit 1
+fi
+
+SCRIPTDIR=$(dirname `perl -e 'use Cwd "abs_path";print abs_path(shift)' $0`)
+TESTFILE=""
+while (( "$#" )); do
+    if [ ${1:0:7} == "http://" -o ${1:0:8} == "https://" ]; then
+        TESTFILE="$TESTFILE $1"
+    else
+        TESTFILE="$TESTFILE `perl -e 'use Cwd "abs_path";print abs_path(shift)' $1`"
+    fi
+    shift
+done
+
+# cleanup previous test runs
+cd $SCRIPTDIR
+rm -f *.xml
+
+# make sure phantomjs submodule is initialized
+cd ..
+git submodule update --init
+
+# fire up the phantomjs environment and run the test
+cd $SCRIPTDIR
+/usr/bin/env phantomjs $SCRIPTDIR/phantomjs-testrunner.js $TESTFILE