diff options
| author | Elliott Sprehn | 2010-10-08 16:43:40 -0700 |
|---|---|---|
| committer | Elliott Sprehn | 2010-10-14 09:47:39 -0700 |
| commit | 03df6cbddbb80186caf571e29957370b2ef9881c (patch) | |
| tree | d5a321c8b207b464a5c8a300c422186e20e8ae31 /src | |
| parent | 0f104317dff5628765e26cc68df7dd1175b2aa5e (diff) | |
| download | angular.js-03df6cbddbb80186caf571e29957370b2ef9881c.tar.bz2 | |
New Angular Scenario runner and DSL system with redesigned HTML UI.
Uses the Jasmine syntax for tests, ex:
describe('widgets', function() {
it('should verify that basic widgets work', function(){
navigateTo('widgets.html');
input('text.basic').enter('Carlos');
expect(binding('text.basic')).toEqual('Carlos');
input('text.basic').enter('Carlos Santana');
expect(binding('text.basic')).not().toEqual('Carlos Boozer');
input('text.password').enter('secret');
expect(binding('text.password')).toEqual('secret');
expect(binding('text.hidden')).toEqual('hiddenValue');
expect(binding('gender')).toEqual('male');
input('gender').select('female');
expect(binding('gender')).toEqual('female');
});
});
Note: To create new UI's implement the interface shown in angular.scenario.ui.Html.
Diffstat (limited to 'src')
| -rw-r--r-- | src/Angular.js | 4 | ||||
| -rw-r--r-- | src/scenario/Application.js | 51 | ||||
| -rw-r--r-- | src/scenario/DSL.js | 249 | ||||
| -rw-r--r-- | src/scenario/Describe.js | 108 | ||||
| -rw-r--r-- | src/scenario/Future.js | 23 | ||||
| -rw-r--r-- | src/scenario/HtmlUI.js | 204 | ||||
| -rw-r--r-- | src/scenario/Matcher.js | 21 | ||||
| -rw-r--r-- | src/scenario/Runner.js | 262 | ||||
| -rw-r--r-- | src/scenario/Scenario.js | 103 | ||||
| -rw-r--r-- | src/scenario/SpecRunner.js | 78 | ||||
| -rw-r--r-- | src/scenario/angular.prefix | 6 | ||||
| -rw-r--r-- | src/scenario/angular.suffix | 28 | ||||
| -rw-r--r-- | src/scenario/bootstrap.js | 62 | ||||
| -rw-r--r-- | src/scenario/matchers.js | 39 |
14 files changed, 882 insertions, 356 deletions
diff --git a/src/Angular.js b/src/Angular.js index 95970850..72f341f3 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -270,11 +270,11 @@ function equals(o1, o2) { } else { keySet = {}; for(key in o1) { - if (key.charAt(0) !== '$' && !equals(o1[key], o2[key])) return false; + if (key.charAt(0) !== '$' && !isFunction(o1[key]) && !equals(o1[key], o2[key])) return false; keySet[key] = true; } for(key in o2) { - if (key.charAt(0) !== '$' && keySet[key] !== true) return false; + if (!keySet[key] && key.charAt(0) !== '$' && !isFunction(o2[key])) return false; } return true; } diff --git a/src/scenario/Application.js b/src/scenario/Application.js new file mode 100644 index 00000000..24ae99e9 --- /dev/null +++ b/src/scenario/Application.js @@ -0,0 +1,51 @@ +/** + * Represents the application currently being tested and abstracts usage + * of iframes or separate windows. + */ +angular.scenario.Application = function(context) { + this.context = context; + context.append('<h2>Current URL: <a href="about:blank">None</a></h2>'); +}; + +/** + * Gets the jQuery collection of frames. Don't use this directly because + * frames may go stale. + * + * @return {Object} jQuery collection + */ +angular.scenario.Application.prototype.getFrame = function() { + return this.context.find('> iframe'); +}; + +/** + * Gets the window of the test runner frame. Always favor executeAction() + * instead of this method since it prevents you from getting a stale window. + * + * @return {Object} the window of the frame + */ +angular.scenario.Application.prototype.getWindow = function() { + var contentWindow = this.getFrame().attr('contentWindow'); + if (!contentWindow) + throw 'No window available because frame not loaded.'; + return contentWindow; +}; + +/** + * Changes the location of the frame. + */ +angular.scenario.Application.prototype.navigateTo = function(url, onloadFn) { + this.getFrame().remove(); + this.context.append('<iframe src=""></iframe>'); + this.context.find('> h2 a').attr('href', url).text(url); + this.getFrame().attr('src', url).load(onloadFn); +}; + +/** + * Executes a function in the context of the tested application. + * + * @param {Function} The callback to execute. function($window, $document) + */ +angular.scenario.Application.prototype.executeAction = function(action) { + var $window = this.getWindow(); + return action.call($window, _jQuery($window.document), $window); +}; diff --git a/src/scenario/DSL.js b/src/scenario/DSL.js index dc85ea45..a7571afe 100644 --- a/src/scenario/DSL.js +++ b/src/scenario/DSL.js @@ -1,131 +1,134 @@ -angular.scenario.dsl.browser = { - navigateTo: function(url){ - var location = this.location; - return $scenario.addFuture('Navigate to: ' + url, function(done){ - var self = this; - this.testFrame.load(function(){ - self.testFrame.unbind(); - self.testWindow = self.testFrame[0].contentWindow; - self.testDocument = self.jQuery(self.testWindow.document); - self.$browser = self.testWindow.angular.service.$browser(); - self.notifyWhenNoOutstandingRequests = - bind(self.$browser, self.$browser.notifyWhenNoOutstandingRequests); - self.notifyWhenNoOutstandingRequests(done); - }); - if (this.testFrame.attr('src') == url) { - this.testFrame[0].contentWindow.location.reload(); - } else { - this.testFrame.attr('src', url); - location.setLocation(url); - } - }); - }, - location: { - href: "", - hash: "", - toEqual: function(url) { - return (this.hash === "" ? (url == this.href) : - (url == (this.href + "/#/" + this.hash))); - }, - setLocation: function(url) { - var urlParts = url.split("/#/"); - this.href = urlParts[0] || ""; - this.hash = urlParts[1] || ""; - } - } -}; - -angular.scenario.dsl.input = function(selector) { - var namePrefix = "input '" + selector + "'"; - return { - enter: function(value) { - return $scenario.addFuture(namePrefix + " enter '" + value + "'", function(done) { - var input = this.testDocument.find('input[name=' + selector + ']'); - input.val(value); - this.testWindow.angular.element(input[0]).trigger('change'); - done(); - }); - }, - select: function(value) { - return $scenario.addFuture(namePrefix + " select '" + value + "'", function(done) { - var input = this.testDocument. - find(':radio[name$=@' + selector + '][value=' + value + ']'); - jqLiteWrap(input[0]).trigger('click'); - input[0].checked = true; - done(); - }); - } - }; -}; +/** + * Shared DSL statements that are useful to all scenarios. + */ -angular.scenario.dsl.NG_BIND_PATTERN =/\{\{[^\}]+\}\}/; +/** +* Usage: +* pause(seconds) pauses the test for specified number of seconds +*/ +angular.scenario.dsl('pause', function() { + return function(time) { + return this.addFuture('pause for ' + time + ' seconds', function(done) { + this.setTimeout(function() { done(null, time * 1000); }, time * 1000); + }); + }; +}); -angular.scenario.dsl.repeater = function(selector) { - var namePrefix = "repeater '" + selector + "'"; - return { - count: function() { - return $scenario.addFuture(namePrefix + ' count', function(done) { - done(this.testDocument.find(selector).size()); - }); - }, - collect: function(collectSelector) { - return $scenario.addFuture( - namePrefix + " collect '" + collectSelector + "'", - function(done) { - var self = this; - var doCollect = bind(this, function() { - var repeaterArray = [], ngBindPattern; - var startIndex = collectSelector.search( - angular.scenario.dsl.NG_BIND_PATTERN); - if (startIndex >= 0) { - ngBindPattern = collectSelector.substring( - startIndex + 2, collectSelector.length - 2); - collectSelector = '*'; - - } - this.testDocument.find(selector).each(function() { - var element = self.jQuery(this); - element.find(collectSelector). - each(function() { - var foundElem = self.jQuery(this); - if (foundElem.attr('ng:bind') == ngBindPattern) { - repeaterArray.push(foundElem.text()); - } - }); - }); - return repeaterArray; - }); - done(doCollect()); - }); - } +/** + * Usage: + * expect(future).{matcher} where matcher is one of the matchers defined + * with angular.scenario.matcher + * + * ex. expect(binding("name")).toEqual("Elliott") + */ +angular.scenario.dsl('expect', function() { + var chain = angular.extend({}, angular.scenario.matcher); + + chain.not = function() { + this.inverse = true; + return chain; + }; + + return function(future) { + this.future = future; + return chain; }; -}; +}); -angular.scenario.dsl.element = function(selector) { - var namePrefix = "Element '" + selector + "'"; - var futureJquery = {}; - for (key in (jQuery || _jQuery).fn) { - (function(){ - var jqFnName = key; - var jqFn = (jQuery || _jQuery).fn[key]; - futureJquery[key] = function() { - var jqArgs = arguments; - return $scenario.addFuture(namePrefix + "." + jqFnName + "()", - function(done) { - var self = this, repeaterArray = [], ngBindPattern; - var startIndex = selector.search(angular.scenario.dsl.NG_BIND_PATTERN); - if (startIndex >= 0) { - ngBindPattern = selector.substring(startIndex + 2, selector.length - 2); - var element = this.testDocument.find('*').filter(function() { - return self.jQuery(this).attr('ng:bind') == ngBindPattern; +/** + * Usage: + * navigateTo(future|string) where url a string or future with a value + * of a URL to navigate to + */ +angular.scenario.dsl('navigateTo', function() { + return function(url) { + 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() { + application.executeAction(function() { + if (this.angular) { + var $browser = this.angular.service.$browser(); + $browser.poll(); + $browser.notifyWhenNoOutstandingRequests(function() { + done(null, url.value || url); }); - done(jqFn.apply(element, jqArgs)); } else { - done(jqFn.apply(this.testDocument.find(selector), jqArgs)); + done(null, url.value || url); } }); - }; - })(); - } - return futureJquery; -}; + }); + }); + }; +}); + +/** + * Usage: + * input(name).enter(value) enters value in input with specified name + * input(name).check() checks checkbox + * input(name).select(value) selects the readio button with specified name/value + */ +angular.scenario.dsl('input', function() { + var chain = {}; + + chain.enter = function(value) { + var spec = this; + return this.addFutureAction("input '" + this.name + "' enter '" + value + "'", function(done) { + var input = _jQuery(this.document).find('input[name=' + spec.name + ']'); + if (!input.length) + return done("Input named '" + spec.name + "' does not exist."); + input.val(value); + this.angular.element(input[0]).trigger('change'); + done(); + }); + }; + + chain.check = function() { + var spec = this; + return this.addFutureAction("checkbox '" + this.name + "' toggle", function(done) { + var input = _jQuery(this.document). + find('input:checkbox[name=' + spec.name + ']'); + if (!input.length) + return done("Input named '" + spec.name + "' does not exist."); + this.angular.element(input[0]).trigger('click'); + input.attr('checked', !input.attr('checked')); + done(); + }); + }; + + chain.select = function(value) { + var spec = this; + return this.addFutureAction("radio button '" + this.name + "' toggle '" + value + "'", function(done) { + var input = _jQuery(this.document). + find('input:radio[name$="@' + spec.name + '"][value="' + value + '"]'); + if (!input.length) + return done("Input named '" + spec.name + "' does not exist."); + this.angular.element(input[0]).trigger('click'); + input.attr('checked', !input.attr('checked')); + done(); + }); + }; + + return function(name) { + this.name = name; + return chain; + }; +}); + +/** + * Usage: + * binding(name) returns the value of a binding + */ +angular.scenario.dsl('binding', function() { + return function(name) { + return this.addFutureAction("select binding '" + name + "'", function(done) { + var element = _jQuery(this.document).find('[ng\\:bind="' + name + '"]'); + if (!element.length) + return done("Binding named '" + name + "' does not exist."); + done(null, element.text()); + }); + }; +}); diff --git a/src/scenario/Describe.js b/src/scenario/Describe.js new file mode 100644 index 00000000..896b337f --- /dev/null +++ b/src/scenario/Describe.js @@ -0,0 +1,108 @@ +/** + * The representation of define blocks. Don't used directly, instead use + * define() in your tests. + */ +angular.scenario.Describe = function(descName, parent) { + this.beforeEachFns = []; + this.afterEachFns = []; + this.its = []; + this.children = []; + this.name = descName; + this.parent = parent; + this.id = angular.scenario.Describe.id++; + + /** + * Calls all before functions. + */ + var beforeEachFns = this.beforeEachFns; + this.setupBefore = function() { + if (parent) parent.setupBefore.call(this); + angular.foreach(beforeEachFns, function(fn) { fn.call(this); }, this); + }; + + /** + * Calls all after functions. + */ + var afterEachFns = this.afterEachFns; + this.setupAfter = function() { + angular.foreach(afterEachFns, function(fn) { fn.call(this); }, this); + if (parent) parent.setupAfter.call(this); + }; +}; + +// Shared Unique ID generator for every describe block +angular.scenario.Describe.id = 0; + +/** + * Defines a block to execute before each it or nested describe. + * + * @param {Function} Body of the block. + */ +angular.scenario.Describe.prototype.beforeEach = function(body) { + this.beforeEachFns.push(body); +}; + +/** + * Defines a block to execute after each it or nested describe. + * + * @param {Function} Body of the block. + */ +angular.scenario.Describe.prototype.afterEach = function(body) { + this.afterEachFns.push(body); +}; + +/** + * Creates a new describe block that's a child of this one. + * + * @param {String} Name of the block. Appended to the parent block's name. + * @param {Function} Body of the block. + */ +angular.scenario.Describe.prototype.describe = function(name, body) { + var child = new angular.scenario.Describe(name, this); + this.children.push(child); + body.call(child); +}; + +/** + * Use to disable a describe block. + */ +angular.scenario.Describe.prototype.xdescribe = angular.noop; + +/** + * Defines a test. + * + * @param {String} Name of the test. + * @param {Function} Body of the block. + */ +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); + } + }); +}; + +/** + * Use to disable a test block. + */ +angular.scenario.Describe.prototype.xit = angular.noop; + +/** + * Gets an array of functions representing all the tests (recursively). + * that can be executed with SpecRunner's. + */ +angular.scenario.Describe.prototype.getSpecs = function() { + var specs = arguments[0] || []; + angular.foreach(this.children, function(child) { + child.getSpecs(specs); + }); + angular.foreach(this.its, function(it) { + specs.push(it); + }); + return specs; +}; diff --git a/src/scenario/Future.js b/src/scenario/Future.js index cc40eff0..60fad9c5 100644 --- a/src/scenario/Future.js +++ b/src/scenario/Future.js @@ -1,13 +1,22 @@ -function Future(name, behavior) { +/** + * A future action in a spec. + */ +angular.scenario.Future = function(name, behavior) { this.name = name; this.behavior = behavior; this.fulfilled = false; - this.value = _undefined; -} + this.value = undefined; +}; -Future.prototype = { - fulfill: function(value) { +/** + * Executes the behavior of the closure. + * + * @param {Function} Callback function(error, result) + */ +angular.scenario.Future.prototype.execute = function(doneFn) { + this.behavior(angular.bind(this, function(error, result) { this.fulfilled = true; - this.value = value; - } + this.value = error || result; + doneFn(error, result); + })); }; diff --git a/src/scenario/HtmlUI.js b/src/scenario/HtmlUI.js new file mode 100644 index 00000000..46c88837 --- /dev/null +++ b/src/scenario/HtmlUI.js @@ -0,0 +1,204 @@ +/** + * User Interface for the Scenario Runner. + * + * @param {Object} The jQuery UI object for the UI. + */ +angular.scenario.ui.Html = function(context) { + this.context = context; + context.append( + '<div id="header">' + + ' <h1><span class="angular"><angular/></span>: Scenario Test Runner</h1>' + + ' <ul id="status-legend" class="status-display">' + + ' <li class="status-error">0 Errors</li>' + + ' <li class="status-failure">0 Failures</li>' + + ' <li class="status-success">0 Passed</li>' + + ' </ul>' + + '</div>' + + '<div id="specs">' + + ' <div class="test-children"></div>' + + '</div>' + ); +}; + +/** + * 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 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) { + var status = this.context.find('#status-legend .status-' + status); + var parts = status.text().split(' '); + var value = (parts[0] * 1) + 1; + status.text(value + ' ' + parts[1]); + }) + ); +}; + +/** + * Finds the context of a spec block defined by the passed definition. + * + * @param {Object} The definition created by the Describe object. + */ +angular.scenario.ui.Html.prototype.findContext = function(definition) { + var path = []; + var currentContext = this.context.find('#specs'); + var currentDefinition = definition; + while (currentDefinition && currentDefinition.name) { + path.unshift(currentDefinition); + currentDefinition = currentDefinition.parent; + } + angular.foreach(path, angular.bind(this, function(defn) { + var id = 'describe-' + defn.id; + if (!this.context.find('#' + id).length) { + currentContext.find('> .test-children').append( + '<div class="test-describe" id="' + id + '">' + + ' <h2></h2>' + + ' <div class="test-children"></div>' + + ' <ul class="tests"></ul>' + + '</div>' + ); + this.context.find('#' + id).find('> h2').text('describe: ' + defn.name); + } + currentContext = this.context.find('#' + id); + })); + return this.context.find('#describe-' + definition.id); +}; + +/** + * A spec block in the UI. + * + * @param {Object} The jQuery object for the context of the spec. + * @param {String} The name of the spec. + * @param {Function} Callback function(status) to call when complete. + */ +angular.scenario.ui.Html.Spec = function(context, name, doneFn) { + this.status = 'pending'; + this.context = context; + this.startTime = new Date().getTime(); + this.doneFn = doneFn; + context.append( + '<div class="test-info">' + + ' <p class="test-title">' + + ' <span class="timer-result"></span>' + + ' <span class="test-name"></span>' + + ' </p>' + + '</div>' + + '<ol class="test-actions">' + + '</ol>' + ); + context.find('> .test-info .test-name').text('it ' + name); +}; + +/** + * Adds a new Step to this spec and returns it. + * + * @param {String} The name of the step. + */ +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'); + var self = this; + return new angular.scenario.ui.Html.Step(stepContext, name, function(status) { + self.status = status; + }); +}; + +/** + * Completes the spec and sets the timer value. + */ +angular.scenario.ui.Html.Spec.prototype.complete = function() { + this.context.removeClass('status-pending'); + var endTime = new Date().getTime(); + this.context.find("> .test-info .timer-result") + .text((endTime - this.startTime) + "ms"); +}; + +/** + * Finishes the spec, possibly with an error. + * + * @param {Object} An optional error + */ +angular.scenario.ui.Html.Spec.prototype.finish = function(error) { + 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); +}; + +/** + * Finishes the spec, but with a Fatal Error. + * + * @param {Object} Required error + */ +angular.scenario.ui.Html.Spec.prototype.error = function(error) { + this.finish(error); +}; + +/** + * A single step inside an it block (or a before/after function). + * + * @param {Object} The jQuery object for the context of the step. + * @param {String} The name of the step. + * @param {Function} Callback function(status) to call when complete. + */ +angular.scenario.ui.Html.Step = function(context, name, doneFn) { + this.context = context; + this.name = name; + this.startTime = new Date().getTime(); + this.doneFn = doneFn; + context.append( + '<span class="timer-result"></span>' + + '<span class="test-title"></span>' + ); + context.find('> .test-title').text(name); +}; + +/** + * Completes the step and sets the timer value. + */ +angular.scenario.ui.Html.Step.prototype.complete = function() { + this.context.removeClass('status-pending'); + var endTime = new Date().getTime(); + this.context.find(".timer-result") + .text((endTime - this.startTime) + "ms"); +}; + +/** + * Finishes the step, possibly with an error. + * + * @param {Object} An optional error + */ +angular.scenario.ui.Html.Step.prototype.finish = function(error) { + this.complete(); + if (error) { + this.context.addClass('status-failure'); + this.doneFn('failure'); + } else { + this.context.addClass('status-success'); + this.doneFn('success'); + } +}; + +/** + * Finishes the step, but with a Fatal Error. + * + * @param {Object} Required error + */ +angular.scenario.ui.Html.Step.prototype.error = function(error) { + this.complete(); + this.context.addClass('status-error'); + this.doneFn('error'); +}; diff --git a/src/scenario/Matcher.js b/src/scenario/Matcher.js deleted file mode 100644 index a9c86571..00000000 --- a/src/scenario/Matcher.js +++ /dev/null @@ -1,21 +0,0 @@ -function Matcher(scope, future, logger) { - var self = scope.$scenario = this; - this.logger = logger; - this.future = future; -} - -Matcher.addMatcher = function(name, matcher) { - Matcher.prototype[name] = function(expected) { - var future = this.future; - $scenario.addFuture( - 'expect ' + future.name + ' ' + name + ' ' + expected, - function(done){ - if (!matcher(future.value, expected)) - throw "Expected " + expected + ' but was ' + future.value; - done(); - } - ); - }; -}; - -Matcher.addMatcher('toEqual', angular.equals); diff --git a/src/scenario/Runner.js b/src/scenario/Runner.js index 77969618..0267bb2d 100644 --- a/src/scenario/Runner.js +++ b/src/scenario/Runner.js @@ -1,183 +1,95 @@ -angular['scenario'] = angular['scenario'] || (angular['scenario'] = {}); -angular.scenario['dsl'] = angular.scenario['dsl'] || (angular.scenario['dsl'] = {}); - -angular.scenario.Runner = function(scope, jQuery){ - var self = scope.$scenario = this; - this.scope = scope; - this.jQuery = jQuery; - this.scope.$testrun = {done: false, results: []}; - - var specs = this.specs = {}; - this.currentSpec = {name: '', futures: []}; - var path = []; - this.scope.describe = function(name, body){ - path.push(name); - body(); - path.pop(); - }; - var beforeEach = noop; - var afterEach = noop; - this.scope.beforeEach = function(body) { - beforeEach = body; - }; - this.scope.afterEach = function(body) { - afterEach = body; - }; - this.scope.expect = function(future) { - return new Matcher(self, future, self.logger); +/** + * Runner for scenarios. + */ +angular.scenario.Runner = function($window) { + this.$window = $window; + this.rootDescribe = new angular.scenario.Describe(); + this.currentDescribe = this.rootDescribe; + this.api = { + it: this.it, + xit: angular.noop, + describe: this.describe, + xdescribe: angular.noop, + beforeEach: this.beforeEach, + afterEach: this.afterEach }; - this.scope.it = function(name, body) { - var specName = path.join(' ') + ': it ' + name; - self.currentSpec = specs[specName] = { - name: specName, - futures: [] - }; + angular.foreach(this.api, angular.bind(this, function(fn, key) { + this.$window[key] = angular.bind(this, fn); + })); +}; + +/** + * Defines a describe block of a spec. + * + * @param {String} Name of the block + * @param {Function} Body of the block + */ +angular.scenario.Runner.prototype.describe = function(name, body) { + var self = this; + this.currentDescribe.describe(name, function() { + var parentDescribe = self.currentDescribe; + self.currentDescribe = this; try { - beforeEach(); - body(); - } catch(err) { - self.addFuture(err.message || 'ERROR', function(){ - throw err; - }); + body.call(this); } finally { - afterEach(); + self.currentDescribe = parentDescribe; } - self.currentSpec = _null; - }; - this.logger = function returnNoop(){ - return extend(returnNoop, {close:noop, fail:noop}); - }; + }); }; -angular.scenario.Runner.prototype = { - run: function(body){ - var jQuery = this.jQuery; - body.append( - '<div id="runner">' + - '<div class="console"></div>' + - '</div>' + - '<div id="testView">' + - '<iframe></iframe>' + - '</div>'); - var console = body.find('#runner .console'); - console.find('li').live('click', function(){ - jQuery(this).toggleClass('collapsed'); - }); - this.testFrame = body.find('#testView iframe'); - function logger(parent) { - var container; - return function(type, text) { - if (!container) { - container = jQuery('<ul></ul>'); - parent.append(container); - } - var element = jQuery('<li class="running '+type+'"><span></span></li>'); - element.find('span').text(text); - container.append(element); - return extend(logger(element), { - close: function(){ - element.removeClass('running'); - if(!element.hasClass('fail')) - element.addClass('collapsed'); - console.scrollTop(console[0].scrollHeight); - }, - fail: function(){ - element.removeClass('running'); - var current = element; - while (current[0] != console[0]) { - if (current.is('li')) - current.addClass('fail'); - current = current.parent(); - } - } - }); - }; - } - this.logger = logger(console); - var specNames = []; - foreach(this.specs, function(spec, name){ - specNames.push(name); - }, this); - specNames.sort(); - var self = this; - function callback(){ - var next = specNames.shift(); - if(next) { - self.execute(next, callback); - } else { - self.scope.$testrun.done = true; - } - } - callback(); - }, +/** + * Defines a test in a describe block of a spec. + * + * @param {String} Name of the block + * @param {Function} Body of the block + */ +angular.scenario.Runner.prototype.it = function(name, body) { + this.currentDescribe.it(name, body); +}; - addFuture: function(name, behavior) { - var future = new Future(name, behavior); - this.currentSpec.futures.push(future); - return future; - }, +/** + * Defines a function to be called before each it block in the describe + * (and before all nested describes). + * + * @param {Function} Callback to execute + */ +angular.scenario.Runner.prototype.beforeEach = function(body) { + this.currentDescribe.beforeEach(body); +}; - execute: function(name, callback) { - var spec = this.specs[name], - self = this, - futuresFulfilled = [], - result = { - passed: false, - failed: false, - finished: false, - fail: function(error) { - result.passed = false; - result.failed = true; - result.error = error; - result.log('fail', isString(error) ? error : toJson(error)).fail(); - } - }, - specThis = createScope({ - result: result, - jQuery: this.jQuery, - testFrame: this.testFrame, - testWindow: this.testWindow - }, angularService, {}); - this.self = specThis; - var futureLogger = this.logger('spec', name); - spec.nextFutureIndex = 0; - function done() { - result.finished = true; - futureLogger.close(); - self.self = _null; - (callback||noop).call(specThis); - } - function next(value){ - if (spec.nextFutureIndex > 0) { - spec.futures[spec.nextFutureIndex - 1].fulfill(value); - } - var future = spec.futures[spec.nextFutureIndex]; - (result.log || {close:noop}).close(); - result.log = _null; - if (future) { - spec.nextFutureIndex ++; - result.log = futureLogger('future', future.name); - futuresFulfilled.push(future.name); - try { - future.behavior.call(specThis, next); - } catch (e) { - console.error(e); - result.fail(e); - self.scope.$testrun.results.push( - {name: name, passed: false, error: e, steps: futuresFulfilled}); - done(); - } - } else { - result.passed = !result.failed; - self.scope.$testrun.results.push({ - name: name, - passed: !result.failed, - error: result.error, - steps: futuresFulfilled}); - done(); - } - } - next(); - return specThis; - } -};
\ No newline at end of file +/** + * Defines a function to be called after each it block in the describe + * (and before all nested describes). + * + * @param {Function} Callback to execute + */ +angular.scenario.Runner.prototype.afterEach = function(body) { + this.currentDescribe.afterEach(body); +}; + +/** + * Defines a function to be called before each it block in the describe + * (and before all nested describes). + * + * @param {Function} Callback to execute + */ +angular.scenario.Runner.prototype.run = function(ui, application, specRunnerClass, specsDone) { + var $root = angular.scope({}, angular.service); + var self = this; + var specs = this.rootDescribe.getSpecs(); + $root.application = application; + $root.ui = ui; + $root.setTimeout = function() { + return self.$window.setTimeout.apply(self.$window, arguments); + }; + asyncForEach(specs, angular.bind(this, function(spec, specDone) { + var runner = angular.scope($root); + runner.$become(specRunnerClass); + angular.foreach(angular.scenario.dsl, angular.bind(this, function(fn, key) { + this.$window[key] = function() { + return fn.call($root).apply(angular.scope(runner), arguments); + } + })); + runner.run(ui, spec, specDone); + }), specsDone || angular.noop); +}; diff --git a/src/scenario/Scenario.js b/src/scenario/Scenario.js new file mode 100644 index 00000000..e93f6b2e --- /dev/null +++ b/src/scenario/Scenario.js @@ -0,0 +1,103 @@ +/** + * Setup file for the Scenario. + * Must be first in the compilation/bootstrap list. + */ + +// Public namespace +angular.scenario = {}; + +// Namespace for the UI +angular.scenario.ui = {}; + +/** + * Defines a new DSL statement. If your factory function returns a Future + * it's returned, otherwise the result is assumed to be a map of functions + * for chaining. Chained functions are subject to the same rules. + * + * Note: All functions on the chain are bound to the chain scope so values + * set on "this" in your statement function are available in the chained + * functions. + * + * @param {String} The name of the statement + * @param {Function} Factory function(application), return a function for + * the statement. + */ +angular.scenario.dsl = function(name, fn) { + angular.scenario.dsl[name] = function() { + function executeStatement(statement, args) { + var result = statement.apply(this, args); + if (angular.isFunction(result) || result instanceof angular.scenario.Future) + return result; + var self = this; + var chain = angular.extend({}, result); + angular.foreach(chain, function(value, name) { + if (angular.isFunction(value)) { + chain[name] = angular.bind(self, function() { + return executeStatement.call(self, value, arguments); + }); + } else { + chain[name] = value; + } + }); + return chain; + } + var statement = fn.apply(this, arguments); + return function() { + return executeStatement.call(this, statement, arguments); + }; + }; +}; + +/** + * Defines a new matcher for use with the expects() statement. The value + * this.actual (like in Jasmine) is available in your matcher to compare + * against. Your function should return a boolean. The future is automatically + * created for you. + * + * @param {String} The name of the matcher + * @param {Function} The matching function(expected). + */ +angular.scenario.matcher = function(name, fn) { + angular.scenario.matcher[name] = function(expected) { + var prefix = 'expect ' + this.future.name + ' '; + 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); + } + done(this.error); + }) + ); + }; +}; + +/** + * Iterates through list with iterator function that must call the + * continueFunction to continute iterating. + * + * @param {Array} list to iterate over + * @param {Function} Callback function(value, continueFunction) + * @param {Function} Callback function(error, result) called when iteration + * finishes or an error occurs. + */ +function asyncForEach(list, iterator, done) { + var i = 0; + function loop(error) { + if (error || i >= list.length) { + done(error); + } else { + try { + iterator(list[i++], loop); + } catch (e) { + done(e); + } + } + } + loop(); +} diff --git a/src/scenario/SpecRunner.js b/src/scenario/SpecRunner.js new file mode 100644 index 00000000..8b6d4ef1 --- /dev/null +++ b/src/scenario/SpecRunner.js @@ -0,0 +1,78 @@ +/** + * This class is the "this" of the it/beforeEach/afterEach method. + * Responsibilities: + * - "this" for it/beforeEach/afterEach + * - keep state for single it/beforeEach/afterEach execution + * - keep track of all of the futures to execute + * - run single spec (execute each future) + */ +angular.scenario.SpecRunner = function() { + this.futures = []; +}; + +/** + * Executes a spec which is an it block with associated before/after functions + * based on the describe nesting. + * + * @param {Object} An angular.scenario.UI implementation + * @param {Object} A spec object + * @param {Object} An angular.scenario.Application instance + * @param {Function} Callback function that is called when the spec finshes. + */ +angular.scenario.SpecRunner.prototype.run = function(ui, spec, specDone) { + var specUI = ui.addSpec(spec); + + try { + spec.fn.call(this); + } catch (e) { + specUI.error(e); + specDone(); + return; + } + + asyncForEach( + this.futures, + function(future, futureDone) { + var stepUI = specUI.addStep(future.name); + try { + future.execute(function(error) { + stepUI.finish(error); + futureDone(error); + }); + } catch (e) { + stepUI.error(e); + rethrow(e); + } + }, + function(e) { + specUI.finish(e); + specDone(); + } + ); +}; + +/** + * Adds a new future action. + * + * @param {String} Name of the future + * @param {Function} Behavior of the future + */ +angular.scenario.SpecRunner.prototype.addFuture = function(name, behavior) { + var future = new angular.scenario.Future(name, angular.bind(this, behavior)); + this.futures.push(future); + return future; +}; + +/** + * Adds a new future action to be executed on the application window. + * + * @param {String} Name of the future + * @param {Function} Behavior of the future + */ +angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior) { + return this.addFuture(name, function(done) { + this.application.executeAction(function() { + behavior.call(this, done); + }); + }); +}; diff --git a/src/scenario/angular.prefix b/src/scenario/angular.prefix index 5b44e17c..a1b4e151 100644 --- a/src/scenario/angular.prefix +++ b/src/scenario/angular.prefix @@ -22,9 +22,3 @@ * THE SOFTWARE. */ (function(window, document, previousOnLoad){ - window.angular = { - scenario: { - dsl: window - } - }; - diff --git a/src/scenario/angular.suffix b/src/scenario/angular.suffix index fc861cbf..53d99dd2 100644 --- a/src/scenario/angular.suffix +++ b/src/scenario/angular.suffix @@ -1,11 +1,31 @@ + var $scenario = new angular.scenario.Runner(window); - var $scenarioRunner = new angular.scenario.Runner(window, jQuery); - - window.onload = function(){ + window.onload = function() { try { if (previousOnLoad) previousOnLoad(); } catch(e) {} - $scenarioRunner.run(jQuery(window.document.body)); + jQuery(document.body).append( + '<div id="runner"></div>' + + '<div id="frame"></div>' + ); + var frame = jQuery('#frame'); + var runner = jQuery('#runner'); + var application = new angular.scenario.Application(frame); + var ui = new angular.scenario.ui.Html(runner); + $scenario.run(ui, application, function(error) { + frame.remove(); + if (error) { + if (window.console) { + console.log(error); + if (error.stack) { + console.log(error.stack); + } + } else { + // Do something for IE + alert(error); + } + } + }); }; })(window, document, window.onload); diff --git a/src/scenario/bootstrap.js b/src/scenario/bootstrap.js index f74305c3..014c636d 100644 --- a/src/scenario/bootstrap.js +++ b/src/scenario/bootstrap.js @@ -1,4 +1,4 @@ -(function(onLoadDelegate){ +(function(previousOnLoad){ var prefix = (function(){ var filename = /(.*\/)bootstrap.js(#(.*))?/; var scripts = document.getElementsByTagName("script"); @@ -10,6 +10,7 @@ } } })(); + function addScript(path) { document.write('<script type="text/javascript" src="' + prefix + path + '"></script>'); } @@ -18,26 +19,51 @@ document.write('<link rel="stylesheet" type="text/css" href="' + prefix + path + '"/>'); } - window.angular = { - scenario: { - dsl: window - } - }; - window.onload = function(){ - setTimeout(function(){ - $scenarioRunner.run(jQuery(window.document.body)); - }, 0); - (onLoadDelegate||function(){})(); + try { + if (previousOnLoad) previousOnLoad(); + } catch(e) {} + _jQuery(document.body).append( + '<div id="runner"></div>' + + '<div id="frame"></div>' + ); + var frame = _jQuery('#frame'); + var runner = _jQuery('#runner'); + var application = new angular.scenario.Application(frame); + var ui = new angular.scenario.ui.Html(runner); + $scenario.run(ui, application, angular.scenario.SpecRunner, function(error) { + frame.remove(); + if (error) { + if (window.console) { + console.log(error.stack || error); + } else { + // Do something for IE + alert(error); + } + } + }); }; + addCSS("../../css/angular-scenario.css"); addScript("../../lib/jquery/jquery-1.4.2.js"); + addScript("../angular-bootstrap.js"); + + addScript("Scenario.js"); + addScript("Application.js"); + addScript("Describe.js"); + addScript("Future.js"); + addScript("HtmlUI.js"); addScript("Runner.js"); - addScript("../Angular.js"); - addScript("../JSON.js"); - addScript("DSL.js"); - document.write('<script type="text/javascript">' + - '$scenarioRunner = new angular.scenario.Runner(window, jQuery);' + - '</script>'); -})(window.onload); + addScript("SpecRunner.js"); + addScript("dsl.js"); + addScript("matchers.js"); + // Create the runner (which also sets up the global API) + document.write( + '<script type="text/javascript">' + + 'var _jQuery = jQuery.noConflict(true);' + + 'var $scenario = new angular.scenario.Runner(window);' + + '</script>' + ); + +})(window.onload); diff --git a/src/scenario/matchers.js b/src/scenario/matchers.js new file mode 100644 index 00000000..0dfbc455 --- /dev/null +++ b/src/scenario/matchers.js @@ -0,0 +1,39 @@ +/** + * Matchers for implementing specs. Follows the Jasmine spec conventions. + */ + +angular.scenario.matcher('toEqual', function(expected) { + return angular.equals(this.actual, expected); +}); + +angular.scenario.matcher('toBeDefined', function() { + return angular.isDefined(this.actual); +}); + +angular.scenario.matcher('toBeTruthy', function() { + return this.actual; +}); + +angular.scenario.matcher('toBeFalsy', function() { + return !this.actual; +}); + +angular.scenario.matcher('toMatch', function(expected) { + return new RegExp(expected).test(this.actual); +}); + +angular.scenario.matcher('toBeNull', function() { + return this.actual === null; +}); + +angular.scenario.matcher('toContain', function(expected) { + return includes(this.actual, expected); +}); + +angular.scenario.matcher('toBeLessThan', function(expected) { + return this.actual < expected; +}); + +angular.scenario.matcher('toBeGreaterThan', function(expected) { + return this.actual > expected; +}); |
