diff options
Diffstat (limited to 'src/scenario')
| -rw-r--r-- | src/scenario/Describe.js | 10 | ||||
| -rw-r--r-- | src/scenario/Future.js | 18 | ||||
| -rw-r--r-- | src/scenario/HtmlUI.js | 102 | ||||
| -rw-r--r-- | src/scenario/Runner.js | 7 | ||||
| -rw-r--r-- | src/scenario/Scenario.js | 97 | ||||
| -rw-r--r-- | src/scenario/SpecRunner.js | 61 | ||||
| -rw-r--r-- | src/scenario/dsl.js | 56 |
7 files changed, 255 insertions, 96 deletions
diff --git a/src/scenario/Describe.js b/src/scenario/Describe.js index 896b337f..f6a52f1e 100644 --- a/src/scenario/Describe.js +++ b/src/scenario/Describe.js @@ -78,12 +78,10 @@ angular.scenario.Describe.prototype.it = function(name, body) { var self = this; this.its.push({ definition: this, - name: name, - fn: function() { - self.setupBefore.call(this); - body.call(this); - self.setupAfter.call(this); - } + name: name, + before: self.setupBefore, + body: body, + after: self.setupAfter }); }; diff --git a/src/scenario/Future.js b/src/scenario/Future.js index 30c2d902..8853aa3f 100644 --- a/src/scenario/Future.js +++ b/src/scenario/Future.js @@ -1,12 +1,17 @@ /** * A future action in a spec. + * + * @param {String} name of the future action + * @param {Function} future callback(error, result) + * @param {String} Optional. function that returns the file/line number. */ -angular.scenario.Future = function(name, behavior) { +angular.scenario.Future = function(name, behavior, line) { this.name = name; this.behavior = behavior; this.fulfilled = false; this.value = undefined; this.parser = angular.identity; + this.line = line || function() { return ''; }; }; /** @@ -15,18 +20,19 @@ angular.scenario.Future = function(name, behavior) { * @param {Function} Callback function(error, result) */ angular.scenario.Future.prototype.execute = function(doneFn) { - this.behavior(angular.bind(this, function(error, result) { - this.fulfilled = true; + var self = this; + this.behavior(function(error, result) { + self.fulfilled = true; if (result) { try { - result = this.parser(result); + result = self.parser(result); } catch(e) { error = e; } } - this.value = error || result; + self.value = error || result; doneFn(error, result); - })); + }); }; /** diff --git a/src/scenario/HtmlUI.js b/src/scenario/HtmlUI.js index a93ed1e3..78fe8c33 100644 --- a/src/scenario/HtmlUI.js +++ b/src/scenario/HtmlUI.js @@ -21,23 +21,29 @@ angular.scenario.ui.Html = function(context) { }; /** + * The severity order of an error. + */ +angular.scenario.ui.Html.SEVERITY = ['pending', 'success', 'failure', 'error']; + +/** * Adds a new spec to the UI. * * @param {Object} The spec object created by the Describe object. */ angular.scenario.ui.Html.prototype.addSpec = function(spec) { + var self = this; var specContext = this.findContext(spec.definition); specContext.find('> .tests').append( '<li class="status-pending test-it"></li>' ); specContext = specContext.find('> .tests li:last'); - return new angular.scenario.ui.Html.Spec(specContext, spec.name, - angular.bind(this, function(status) { - status = this.context.find('#status-legend .status-' + status); + return new angular.scenario.ui.Html.Spec(specContext, spec.name, + function(status) { + status = self.context.find('#status-legend .status-' + status); var parts = status.text().split(' '); var value = (parts[0] * 1) + 1; status.text(value + ' ' + parts[1]); - }) + } ); }; @@ -47,6 +53,7 @@ angular.scenario.ui.Html.prototype.addSpec = function(spec) { * @param {Object} The definition created by the Describe object. */ angular.scenario.ui.Html.prototype.findContext = function(definition) { + var self = this; var path = []; var currentContext = this.context.find('#specs'); var currentDefinition = definition; @@ -54,9 +61,9 @@ angular.scenario.ui.Html.prototype.findContext = function(definition) { path.unshift(currentDefinition); currentDefinition = currentDefinition.parent; } - angular.foreach(path, angular.bind(this, function(defn) { + angular.foreach(path, function(defn) { var id = 'describe-' + defn.id; - if (!this.context.find('#' + id).length) { + if (!self.context.find('#' + id).length) { currentContext.find('> .test-children').append( '<div class="test-describe" id="' + id + '">' + ' <h2></h2>' + @@ -64,10 +71,10 @@ angular.scenario.ui.Html.prototype.findContext = function(definition) { ' <ul class="tests"></ul>' + '</div>' ); - this.context.find('#' + id).find('> h2').text('describe: ' + defn.name); + self.context.find('#' + id).find('> h2').text('describe: ' + defn.name); } - currentContext = this.context.find('#' + id); - })); + currentContext = self.context.find('#' + id); + }); return this.context.find('#describe-' + definition.id); }; @@ -90,9 +97,24 @@ angular.scenario.ui.Html.Spec = function(context, name, doneFn) { ' <span class="test-name"></span>' + ' </p>' + '</div>' + - '<ol class="test-actions">' + - '</ol>' + '<div class="scrollpane">' + + ' <ol class="test-actions">' + + ' </ol>' + + '</div>' ); + context.find('> .test-info').click(function() { + var scrollpane = context.find('> .scrollpane'); + var actions = scrollpane.find('> .test-actions'); + var name = context.find('> .test-info .test-name'); + if (actions.find(':visible').length) { + actions.hide(); + name.removeClass('open').addClass('closed'); + } else { + actions.show(); + scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); + name.removeClass('closed').addClass('open'); + } + }); context.find('> .test-info .test-name').text('it ' + name); }; @@ -100,13 +122,20 @@ angular.scenario.ui.Html.Spec = function(context, name, doneFn) { * Adds a new Step to this spec and returns it. * * @param {String} The name of the step. + * @param {Function} function() that returns a string with the file/line number + * where the step was added from. */ -angular.scenario.ui.Html.Spec.prototype.addStep = function(name) { - this.context.find('> .test-actions').append('<li class="status-pending"></li>'); - var stepContext = this.context.find('> .test-actions li:last'); +angular.scenario.ui.Html.Spec.prototype.addStep = function(name, location) { + this.context.find('> .scrollpane .test-actions').append('<li class="status-pending"></li>'); + var stepContext = this.context.find('> .scrollpane .test-actions li:last'); var self = this; - return new angular.scenario.ui.Html.Step(stepContext, name, function(status) { - self.status = status; + return new angular.scenario.ui.Html.Step(stepContext, name, location, function(status) { + if (indexOf(angular.scenario.ui.Html.SEVERITY, status) > + indexOf(angular.scenario.ui.Html.SEVERITY, self.status)) { + self.status = status; + } + var scrollpane = self.context.find('> .scrollpane'); + scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); }); }; @@ -118,6 +147,10 @@ angular.scenario.ui.Html.Spec.prototype.complete = function() { var endTime = new Date().getTime(); this.context.find("> .test-info .timer-result"). text((endTime - this.startTime) + "ms"); + if (this.status === 'success') { + this.context.find('> .test-info .test-name').addClass('closed'); + this.context.find('> .scrollpane .test-actions').hide(); + } }; /** @@ -125,15 +158,8 @@ angular.scenario.ui.Html.Spec.prototype.complete = function() { * * @param {Object} An optional error */ -angular.scenario.ui.Html.Spec.prototype.finish = function(error) { +angular.scenario.ui.Html.Spec.prototype.finish = function() { this.complete(); - if (error) { - if (this.status !== 'failure') { - this.status = 'error'; - } - this.context.append('<pre></pre>'); - this.context.find('pre:first').text(error.stack || error.toString()); - } this.context.addClass('status-' + this.status); this.doneFn(this.status); }; @@ -144,7 +170,10 @@ angular.scenario.ui.Html.Spec.prototype.finish = function(error) { * @param {Object} Required error */ angular.scenario.ui.Html.Spec.prototype.error = function(error) { - this.finish(error); + this.status = 'error'; + this.context.append('<pre></pre>'); + this.context.find('> pre').text(formatException(error)); + this.finish(); }; /** @@ -152,28 +181,39 @@ angular.scenario.ui.Html.Spec.prototype.error = function(error) { * * @param {Object} The jQuery object for the context of the step. * @param {String} The name of the step. + * @param {Function} function() that returns file/line number of step. * @param {Function} Callback function(status) to call when complete. */ -angular.scenario.ui.Html.Step = function(context, name, doneFn) { +angular.scenario.ui.Html.Step = function(context, name, location, doneFn) { this.context = context; this.name = name; + this.location = location; this.startTime = new Date().getTime(); this.doneFn = doneFn; context.append( - '<span class="timer-result"></span>' + - '<span class="test-title"></span>' + '<div class="timer-result"></div>' + + '<div class="test-title"></div>' ); context.find('> .test-title').text(name); + var scrollpane = context.parents('.scrollpane'); + scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); }; /** * Completes the step and sets the timer value. */ -angular.scenario.ui.Html.Step.prototype.complete = function() { +angular.scenario.ui.Html.Step.prototype.complete = function(error) { this.context.removeClass('status-pending'); var endTime = new Date().getTime(); this.context.find(".timer-result"). text((endTime - this.startTime) + "ms"); + if (error) { + if (!this.context.find('.test-title pre').length) { + this.context.find('.test-title').append('<pre></pre>'); + } + var message = _jQuery.trim(this.location() + '\n\n' + formatException(error)); + this.context.find('.test-title pre').text(message); + } }; /** @@ -182,7 +222,7 @@ angular.scenario.ui.Html.Step.prototype.complete = function() { * @param {Object} An optional error */ angular.scenario.ui.Html.Step.prototype.finish = function(error) { - this.complete(); + this.complete(error); if (error) { this.context.addClass('status-failure'); this.doneFn('failure'); @@ -198,7 +238,7 @@ angular.scenario.ui.Html.Step.prototype.finish = function(error) { * @param {Object} Required error */ angular.scenario.ui.Html.Step.prototype.error = function(error) { - this.complete(); + this.complete(error); this.context.addClass('status-error'); this.doneFn('error'); }; diff --git a/src/scenario/Runner.js b/src/scenario/Runner.js index 55360592..a8b23f83 100644 --- a/src/scenario/Runner.js +++ b/src/scenario/Runner.js @@ -91,6 +91,7 @@ angular.scenario.Runner.prototype.run = function(ui, application, specRunnerClas }); angular.foreach(angular.scenario.dsl, function(fn, key) { self.$window[key] = function() { + var line = callerFile(3); var scope = angular.scope(runner); // Make the dsl accessible on the current chain @@ -103,10 +104,12 @@ angular.scenario.Runner.prototype.run = function(ui, application, specRunnerClas // Make these methods work on the current chain scope.addFuture = function() { - return angular.scenario.SpecRunner.prototype.addFuture.apply(scope, arguments); + Array.prototype.push.call(arguments, line); + return specRunnerClass.prototype.addFuture.apply(scope, arguments); }; scope.addFutureAction = function() { - return angular.scenario.SpecRunner.prototype.addFutureAction.apply(scope, arguments); + Array.prototype.push.call(arguments, line); + return specRunnerClass.prototype.addFutureAction.apply(scope, arguments); }; return scope.dsl[key].apply(scope, arguments); diff --git a/src/scenario/Scenario.js b/src/scenario/Scenario.js index 979210e5..b1782cf2 100644 --- a/src/scenario/Scenario.js +++ b/src/scenario/Scenario.js @@ -32,9 +32,9 @@ angular.scenario.dsl = angular.scenario.dsl || function(name, fn) { var chain = angular.extend({}, result); angular.foreach(chain, function(value, name) { if (angular.isFunction(value)) { - chain[name] = angular.bind(self, function() { + chain[name] = function() { return executeStatement.call(self, value, arguments); - }); + }; } else { chain[name] = value; } @@ -63,17 +63,18 @@ angular.scenario.matcher = angular.scenario.matcher || function(name, fn) { if (this.inverse) { prefix += 'not '; } - this.addFuture(prefix + name + ' ' + angular.toJson(expected), - angular.bind(this, function(done) { - this.actual = this.future.value; - if ((this.inverse && fn.call(this, expected)) || - (!this.inverse && !fn.call(this, expected))) { - this.error = 'expected ' + angular.toJson(expected) + - ' but was ' + angular.toJson(this.actual); + var self = this; + this.addFuture(prefix + name + ' ' + angular.toJson(expected), + function(done) { + var error; + self.actual = self.future.value; + if ((self.inverse && fn.call(self, expected)) || + (!self.inverse && !fn.call(self, expected))) { + error = 'expected ' + angular.toJson(expected) + + ' but was ' + angular.toJson(self.actual); } - done(this.error); - }) - ); + done(error); + }); }; }; @@ -88,7 +89,10 @@ angular.scenario.matcher = angular.scenario.matcher || function(name, fn) { */ function asyncForEach(list, iterator, done) { var i = 0; - function loop(error) { + function loop(error, index) { + if (index && index > i) { + i = index; + } if (error || i >= list.length) { done(error); } else { @@ -102,7 +106,63 @@ function asyncForEach(list, iterator, done) { loop(); } +/** + * Formats an exception into a string with the stack trace, but limits + * to a specific line length. + * + * @param {Object} the exception to format, can be anything throwable + * @param {Number} Optional. max lines of the stack trace to include + * default is 5. + */ +function formatException(error, maxStackLines) { + maxStackLines = maxStackLines || 5; + var message = error.toString(); + if (error.stack) { + var stack = error.stack.split('\n'); + if (stack[0].indexOf(message) === -1) { + maxStackLines++; + stack.unshift(error.message); + } + message = stack.slice(0, maxStackLines).join('\n'); + } + return message; +} +/** + * Returns a function that gets the file name and line number from a + * location in the stack if available based on the call site. + * + * Note: this returns another function because accessing .stack is very + * expensive in Chrome. + */ +function callerFile(offset) { + var error = new Error(); + + return function() { + var line = (error.stack || '').split('\n')[offset]; + + // Clean up the stack trace line + if (line) { + if (line.indexOf('@') !== -1) { + // Firefox + line = line.substring(line.indexOf('@')+1); + } else { + // Chrome + line = line.substring(line.indexOf('(')+1).replace(')', ''); + } + } + + return line || ''; + }; +} + +/** + * Triggers a browser event. Attempts to choose the right event if one is + * not specified. + * + * @param {Object} Either a wrapped jQuery/jqLite node or a DOMElement + * @param {String} Optional event type. + */ function browserTrigger(element, type) { if (element && !element.nodeName) element = element[0]; if (!element) return; @@ -136,10 +196,17 @@ function browserTrigger(element, type) { } } +/** + * Don't use the jQuery trigger method since it works incorrectly. + * + * jQuery notifies listeners and then changes the state of a checkbox and + * does not create a real browser event. A real click changes the state of + * the checkbox and then notifies listeners. + * + * To work around this we instead use our own handler that fires a real event. + */ _jQuery.fn.trigger = function(type) { return this.each(function(index, node) { browserTrigger(node, type); }); }; - - diff --git a/src/scenario/SpecRunner.js b/src/scenario/SpecRunner.js index 26fa9b91..98ce4b53 100644 --- a/src/scenario/SpecRunner.js +++ b/src/scenario/SpecRunner.js @@ -8,6 +8,7 @@ */ angular.scenario.SpecRunner = function() { this.futures = []; + this.afterIndex = 0; }; /** @@ -20,32 +21,52 @@ angular.scenario.SpecRunner = function() { * @param {Function} Callback function that is called when the spec finshes. */ angular.scenario.SpecRunner.prototype.run = function(ui, spec, specDone) { + var self = this; var specUI = ui.addSpec(spec); try { - spec.fn.call(this); + spec.before.call(this); + spec.body.call(this); + this.afterIndex = this.futures.length; + spec.after.call(this); } catch (e) { specUI.error(e); specDone(); return; } + var handleError = function(error, done) { + if (self.error) { + return done(); + } + self.error = true; + done(null, self.afterIndex); + }; + + var spec = this; asyncForEach( this.futures, function(future, futureDone) { - var stepUI = specUI.addStep(future.name); + var stepUI = specUI.addStep(future.name, future.line); try { future.execute(function(error) { stepUI.finish(error); - futureDone(error); + if (error) { + return handleError(error, futureDone); + } + spec.$window.setTimeout( function() { futureDone(); }, 0); }); } catch (e) { stepUI.error(e); - throw e; + handleError(e, futureDone); } }, function(e) { - specUI.finish(e); + if (e) { + specUI.error(e); + } else { + specUI.finish(); + } specDone(); } ); @@ -54,11 +75,14 @@ angular.scenario.SpecRunner.prototype.run = function(ui, spec, specDone) { /** * Adds a new future action. * + * Note: Do not pass line manually. It happens automatically. + * * @param {String} Name of the future * @param {Function} Behavior of the future + * @param {Function} fn() that returns file/line number */ -angular.scenario.SpecRunner.prototype.addFuture = function(name, behavior) { - var future = new angular.scenario.Future(name, angular.bind(this, behavior)); +angular.scenario.SpecRunner.prototype.addFuture = function(name, behavior, line) { + var future = new angular.scenario.Future(name, angular.bind(this, behavior), line); this.futures.push(future); return future; }; @@ -66,17 +90,20 @@ angular.scenario.SpecRunner.prototype.addFuture = function(name, behavior) { /** * Adds a new future action to be executed on the application window. * + * Note: Do not pass line manually. It happens automatically. + * * @param {String} Name of the future * @param {Function} Behavior of the future + * @param {Function} fn() that returns file/line number */ -angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior) { +angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior, line) { + var self = this; return this.addFuture(name, function(done) { - this.application.executeAction(angular.bind(this, function($window, $document) { - - $document.elements = angular.bind(this, function(selector) { + this.application.executeAction(function($window, $document) { + $document.elements = function(selector) { var args = Array.prototype.slice.call(arguments, 1); - if (this.selector) { - selector = this.selector + ' ' + (selector || ''); + if (self.selector) { + selector = self.selector + ' ' + (selector || ''); } angular.foreach(args, function(value, index) { selector = selector.replace('$' + (index + 1), value); @@ -90,10 +117,10 @@ angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior) } return result; - }); + }; try { - behavior.call(this, $window, $document, done); + behavior.call(self, $window, $document, done); } catch(e) { if (e.type && e.type === 'selector') { done(e.message); @@ -101,6 +128,6 @@ angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior) throw e; } } - })); - }); + }); + }, line); }; diff --git a/src/scenario/dsl.js b/src/scenario/dsl.js index 69af39db..f4484df8 100644 --- a/src/scenario/dsl.js +++ b/src/scenario/dsl.js @@ -1,6 +1,18 @@ /** * Shared DSL statements that are useful to all scenarios. */ + + /** + * Usage: + * wait() waits until you call resume() in the console + */ + angular.scenario.dsl('wait', function() { + return function() { + return this.addFuture('waiting for you to call resume() in the console', function(done) { + this.$window.resume = function() { done(); }; + }); + }; + }); /** * Usage: @@ -41,23 +53,22 @@ angular.scenario.dsl('expect', function() { * of a URL to navigate to */ angular.scenario.dsl('navigateTo', function() { - return function(url) { + return function(url, delegate) { var application = this.application; - var name = url; - if (url.name) { - name = ' value of ' + url.name; - } - return this.addFuture('navigate to ' + name, function(done) { - application.navigateTo(url.value || url, function() { + return this.addFuture('navigate to ' + url, function(done) { + if (delegate) { + url = delegate.call(this, url); + } + application.navigateTo(url, function() { application.executeAction(function($window) { if ($window.angular) { var $browser = $window.angular.service.$browser(); $browser.poll(); $browser.notifyWhenNoOutstandingRequests(function() { - done(null, url.value || url); + done(null, url); }); } else { - done(null, url.value || url); + done(null, url); } }); }); @@ -142,9 +153,9 @@ angular.scenario.dsl('input', function() { /** * Usage: - * repeater('#products table').count() // number of rows - * repeater('#products table').row(1) // all bindings in row as an array - * repeater('#products table').column('product.name') // all values across all rows in an array + * repeater('#products table').count() number of rows + * repeater('#products table').row(1) all bindings in row as an array + * repeater('#products table').column('product.name') all values across all rows in an array */ angular.scenario.dsl('repeater', function() { var chain = {}; @@ -194,8 +205,8 @@ angular.scenario.dsl('repeater', function() { /** * Usage: - * select(selector).option('value') // select one option - * select(selector).options('value1', 'value2', ...) // select options from a multi select + * select(selector).option('value') select one option + * select(selector).options('value1', 'value2', ...) select options from a multi select */ angular.scenario.dsl('select', function() { var chain = {}; @@ -227,11 +238,12 @@ angular.scenario.dsl('select', function() { /** * Usage: - * element(selector).click() // clicks an element - * element(selector).attr(name) // gets the value of an attribute - * element(selector).attr(name, value) // sets the value of an attribute - * element(selector).val() // gets the value (as defined by jQuery) - * element(selector).val(value) // sets the value (as defined by jQuery) + * element(selector).click() clicks an element + * element(selector).attr(name) gets the value of an attribute + * element(selector).attr(name, value) sets the value of an attribute + * element(selector).val() gets the value (as defined by jQuery) + * element(selector).val(value) sets the value (as defined by jQuery) + * element(selector).query(fn) executes fn(selectedElements, done) */ angular.scenario.dsl('element', function() { var chain = {}; @@ -263,6 +275,12 @@ angular.scenario.dsl('element', function() { }); }; + chain.query = function(fn) { + return this.addFutureAction('element ' + this.selector + ' custom query', function($window, $document, done) { + fn.call(this, $document.elements(), done); + }); + }; + return function(selector) { this.dsl.using(selector); return chain; |
