aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Angular.js4
-rw-r--r--src/scenario/Application.js51
-rw-r--r--src/scenario/DSL.js249
-rw-r--r--src/scenario/Describe.js108
-rw-r--r--src/scenario/Future.js23
-rw-r--r--src/scenario/HtmlUI.js204
-rw-r--r--src/scenario/Matcher.js21
-rw-r--r--src/scenario/Runner.js262
-rw-r--r--src/scenario/Scenario.js103
-rw-r--r--src/scenario/SpecRunner.js78
-rw-r--r--src/scenario/angular.prefix6
-rw-r--r--src/scenario/angular.suffix28
-rw-r--r--src/scenario/bootstrap.js62
-rw-r--r--src/scenario/matchers.js39
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">&lt;angular/&gt;</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;
+});