From 03df6cbddbb80186caf571e29957370b2ef9881c Mon Sep 17 00:00:00 2001 From: Elliott Sprehn Date: Fri, 8 Oct 2010 16:43:40 -0700 Subject: 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. --- Rakefile | 109 +++++++----- css/angular-scenario.css | 211 +++++++++++++++++----- jsTestDriver-jquery.conf | 2 +- jsTestDriver.conf | 2 +- scenario/style.css | 4 + scenario/widgets-scenario.js | 73 +++++--- src/Angular.js | 4 +- src/scenario/Application.js | 51 ++++++ src/scenario/DSL.js | 249 +++++++++++++------------- src/scenario/Describe.js | 108 ++++++++++++ src/scenario/Future.js | 23 ++- src/scenario/HtmlUI.js | 204 ++++++++++++++++++++++ src/scenario/Matcher.js | 21 --- src/scenario/Runner.js | 262 +++++++++------------------ src/scenario/Scenario.js | 103 +++++++++++ src/scenario/SpecRunner.js | 78 +++++++++ src/scenario/angular.prefix | 6 - src/scenario/angular.suffix | 28 ++- src/scenario/bootstrap.js | 62 +++++-- src/scenario/matchers.js | 39 +++++ test/AngularSpec.js | 4 + test/scenario/ApplicationSpec.js | 75 ++++++++ test/scenario/DSLSpec.js | 369 ++++++++++++++++++++++----------------- test/scenario/DescribeSpec.js | 85 +++++++++ test/scenario/FutureSpec.js | 38 ++++ test/scenario/HtmlUISpec.js | 87 +++++++++ test/scenario/MatcherSpec.js | 38 ---- test/scenario/RunnerSpec.js | 302 +++++++++----------------------- test/scenario/SpecRunnerSpec.js | 165 +++++++++++++++++ test/scenario/TestContext.js | 15 -- test/scenario/matchersSpec.js | 43 +++++ test/testabilityPatch.js | 19 ++ 32 files changed, 1979 insertions(+), 900 deletions(-) create mode 100644 src/scenario/Application.js create mode 100644 src/scenario/Describe.js create mode 100644 src/scenario/HtmlUI.js delete mode 100644 src/scenario/Matcher.js create mode 100644 src/scenario/Scenario.js create mode 100644 src/scenario/SpecRunner.js create mode 100644 src/scenario/matchers.js create mode 100644 test/scenario/ApplicationSpec.js create mode 100644 test/scenario/DescribeSpec.js create mode 100644 test/scenario/FutureSpec.js create mode 100644 test/scenario/HtmlUISpec.js delete mode 100644 test/scenario/MatcherSpec.js create mode 100644 test/scenario/SpecRunnerSpec.js delete mode 100644 test/scenario/TestContext.js create mode 100644 test/scenario/matchersSpec.js diff --git a/Rakefile b/Rakefile index 66a1e77b..fc20dc01 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,46 @@ include FileUtils +ANGULAR = [ + 'src/Angular.js', + 'src/JSON.js', + 'src/Compiler.js', + 'src/Scope.js', + 'src/Injector.js', + 'src/Parser.js', + 'src/Resource.js', + 'src/Browser.js', + 'src/jqLite.js', + 'src/apis.js', + 'src/filters.js', + 'src/formatters.js', + 'src/validators.js', + 'src/services.js', + 'src/directives.js', + 'src/markups.js', + 'src/widgets.js', + 'src/AngularPublic.js', +] + +ANGULAR_SCENARIO = [ + 'src/scenario/Scenario.js', + 'src/scenario/Application.js', + 'src/scenario/Describe.js', + 'src/scenario/Future.js', + 'src/scenario/HtmlUI.js', + 'src/scenario/Describe.js', + 'src/scenario/Runner.js', + 'src/scenario/SpecRunner.js', + 'src/scenario/dsl.js', + 'src/scenario/matchers.js', +] + +GENERATED_FILES = [ + 'angular-debug.js', + 'angular-minified.js', + 'angular-minified.map', + 'angular-scenario.js', +] + task :default => [:compile, :test] desc 'Generate Externs' @@ -20,31 +61,27 @@ task :compile_externs do out.close end +desc 'Clean Generated Files' +task :clean do + GENERATED_FILES.each do |file| + `rm #{file}` + end +end + desc 'Compile Scenario' task :compile_scenario do - concat = %x(cat \ - lib/jquery/jquery-1.4.2.js \ - src/scenario/angular.prefix \ - src/Angular.js \ - src/jqLite.js \ - src/JSON.js \ - src/Scope.js \ - src/Injector.js \ - src/Parser.js \ - src/Resource.js \ - src/Browser.js \ - src/apis.js \ - src/services.js \ - src/AngularPublic.js \ - src/scenario/DSL.js \ - src/scenario/Future.js \ - src/scenario/Matcher.js \ - src/scenario/Runner.js \ - src/scenario/angular.suffix \ - ) + + deps = [ + 'lib/jquery/jquery-1.4.2.js', + 'src/scenario/angular.prefix', + ANGULAR, + ANGULAR_SCENARIO, + 'src/scenario/angular.suffix', + ] css = %x(cat css/angular-scenario.css) + concat = 'cat ' + deps.flatten.join(' ') f = File.new("angular-scenario.js", 'w') - f.write(concat) + f.write(%x{#{concat}}) f.write('document.write(\'\');') @@ -54,30 +91,14 @@ end desc 'Compile JavaScript' task :compile => [:compile_externs, :compile_scenario] do - concat = %x(cat \ - src/angular.prefix \ - src/Angular.js \ - src/JSON.js \ - src/Compiler.js \ - src/Scope.js \ - src/Injector.js \ - src/Parser.js \ - src/Resource.js \ - src/Browser.js \ - src/jqLite.js \ - src/apis.js \ - src/filters.js \ - src/formatters.js \ - src/validators.js \ - src/services.js \ - src/directives.js \ - src/markups.js \ - src/widgets.js \ - src/AngularPublic.js \ - src/angular.suffix \ - ) + deps = [ + 'src/angular.prefix', + ANGULAR, + 'src/angular.suffix', + ] f = File.new("angular-debug.js", 'w') - f.write(concat) + concat = 'cat ' + deps.flatten.join(' ') + f.write(%x{#{concat}}) f.close %x(java -jar lib/compiler-closure/compiler.jar \ diff --git a/css/angular-scenario.css b/css/angular-scenario.css index 3960c357..2cf24b19 100644 --- a/css/angular-scenario.css +++ b/css/angular-scenario.css @@ -1,76 +1,199 @@ @charset "UTF-8"; /* CSS Document */ -#runner { - position: absolute; - top:5px; - left:10px; - right:10px; - height: 200px; +/** Structure */ +body { + font-family: Arial, sans-serif; + margin: 0; + font-size: 14px; } -.console { - display: block; - overflow: scroll; - height: 200px; - border: 1px solid black; +#header { + position: fixed; + width: 100%; +} + +#specs { + padding-top: 50px; +} + +#header .angular { + font-family: Courier New, monospace; + font-weight: bold; +} + +#header h1 { + font-weight: normal; + float: left; + font-size: 30px; + line-height: 30px; + margin: 0; + padding: 10px 10px; + height: 30px; +} + +#frame h2, +#specs h2 { + margin: 0; + padding: 0.5em; + font-size: 1.1em; +} + +#status-legend { + margin-top: 10px; + margin-right: 10px; +} + +#header, +#frame, +.test-info, +.test-actions li { + overflow: hidden; } -#testView { - position: absolute; - bottom:10px; - top:230px; - left:10px; - right:10px; +#frame { + margin: 10px; } -#testView iframe { +#frame iframe { width: 100%; - height: 100%; + height: 758px; +} + +#frame .popout { + float: right; } -li.running > span { - background-color: yellow; +#frame iframe { + border: none; +} + +.tests li, +.test-actions li, +.test-it li, +.test-it ol, +.status-display { + list-style-type: none; } -#runner span { - background-color: green; +.tests, +.test-it ol, +.status-display { + margin: 0; + padding: 0; } -#runner .fail > span { - background-color: red; +.test-info { + margin-left: 1em; + margin-top: 0.5em; + border-radius: 8px 0 0 8px; + -webkit-border-radius: 8px 0 0 8px; + -moz-border-radius: 8px 0 0 8px; +} + +.test-it ol { + margin-left: 2.5em; } -.collapsed > ul { - display: none; +.status-display, +.status-display li { + float: right; } -////// +.status-display li { + padding: 5px 10px; +} -.run, .info, .error { - display: block; - padding: 0 1em; +.timer-result, +.test-title { + display: inline-block; + margin: 0; + padding: 4px; +} + +.timer-result { + width: 4em; + padding: 0 10px; + text-align: right; font-family: monospace; - white-space: pre; } -.run { - background-color: lightgrey; - padding: 0 .2em; +.test-it pre, +.test-actions pre { + clear: left; + margin-left: 6em; } -.run.pass { - background-color: lightgreen; +.test-describe .test-describe { + margin: 5px 5px 10px 2em; } -.run.fail { - background-color: lightred; +.test-actions .status-pending .test-title:before { + content: 'ยป '; +} + +/** Colors */ + +#header { + background-color: #F2C200; } -.name, .time, .state { - padding-right: 2em; +#specs h2 { + border-top: 2px solid #BABAD1; } -error { - color: red; -} \ No newline at end of file +#specs h2, +#frame h2 { + background-color: #efefef; +} + +#frame { + border: 1px solid #BABAD1; +} + +.test-describe .test-describe { + border-left: 1px solid #BABAD1; + border-right: 1px solid #BABAD1; + border-bottom: 1px solid #BABAD1; +} + +.status-display { + border: 1px solid #777; +} + +.status-display .status-pending, +.status-pending .test-info { + background-color: #F9EEBC; +} + +.status-display .status-success, +.status-success .test-info { + background-color: #B1D7A1; +} + +.status-display .status-failure, +.status-failure .test-info { + background-color: #FF8286; +} + +.status-display .status-error, +.status-error .test-info { + background-color: black; + color: white; +} + +.test-actions .status-success .test-title { + color: #30B30A; +} + +.test-actions .status-failure .test-title { + color: #DF0000; +} + +.test-actions .status-error .test-title { + color: black; +} + +.test-actions .timer-result { + color: #888; +} diff --git a/jsTestDriver-jquery.conf b/jsTestDriver-jquery.conf index ed58d269..26bdd614 100644 --- a/jsTestDriver-jquery.conf +++ b/jsTestDriver-jquery.conf @@ -9,7 +9,7 @@ load: - src/JSON.js - src/*.js - test/testabilityPatch.js - - src/scenario/Runner.js + - src/scenario/Scenario.js - src/scenario/*.js - test/angular-mocks.js - test/scenario/*.js diff --git a/jsTestDriver.conf b/jsTestDriver.conf index c7d74b75..9d3d980d 100644 --- a/jsTestDriver.conf +++ b/jsTestDriver.conf @@ -9,7 +9,7 @@ load: - src/JSON.js - src/*.js - test/testabilityPatch.js - - src/scenario/Runner.js + - src/scenario/Scenario.js - src/scenario/*.js - test/angular-mocks.js - test/scenario/*.js diff --git a/scenario/style.css b/scenario/style.css index 956bdc52..43690e2c 100644 --- a/scenario/style.css +++ b/scenario/style.css @@ -5,3 +5,7 @@ th { tr { border: 1px solid black; } + +.redbox { + background-color: red; +} \ No newline at end of file diff --git a/scenario/widgets-scenario.js b/scenario/widgets-scenario.js index f4488190..69fdc10e 100644 --- a/scenario/widgets-scenario.js +++ b/scenario/widgets-scenario.js @@ -1,25 +1,58 @@ -describe('widgets', function(){ +describe('widgets', function() { it('should verify that basic widgets work', function(){ - browser.navigateTo('widgets.html'); - - expect('{{text.basic}}').toEqual(''); - input('text.basic').enter('John'); - expect('{{text.basic}}').toEqual('John'); - - expect('{{text.password}}').toEqual(''); + navigateTo('widgets.html'); + input('text.basic').enter('Carlos'); + expect(binding('text.basic')).toEqual('Carlos'); + pause(2); + input('text.basic').enter('Carlos Santana'); + pause(2); + expect(binding('text.basic')).not().toEqual('Carlos Boozer'); + pause(2); input('text.password').enter('secret'); - expect('{{text.password}}').toEqual('secret'); - - expect('{{text.hidden}}').toEqual('hiddenValue'); - - expect('{{gender}}').toEqual('male'); + expect(binding('text.password')).toEqual('secret'); + expect(binding('text.hidden')).toEqual('hiddenValue'); + expect(binding('gender')).toEqual('male'); + pause(2); input('gender').select('female'); - input('gender').isChecked('female'); - expect('{{gender}}').toEqual('female'); - -// expect('{{tea}}').toBeChecked(); -// input('gender').select('female'); -// expect('{{gender}}').toEqual('female'); - + expect(binding('gender')).toEqual('female'); + pause(2); + }); + describe('do it again', function() { + it('should verify that basic widgets work', function(){ + navigateTo('widgets.html'); + input('text.basic').enter('Carlos'); + expect(binding('text.basic')).toEqual('Carlos'); + pause(2); + input('text.basic').enter('Carlos Santana'); + pause(2); + expect(binding('text.basic')).toEqual('Carlos Santana'); + pause(2); + input('text.password').enter('secret'); + expect(binding('text.password')).toEqual('secret'); + expect(binding('text.hidden')).toEqual('hiddenValue'); + expect(binding('gender')).toEqual('male'); + pause(2); + input('gender').select('female'); + expect(binding('gender')).toEqual('female'); + pause(2); + }); + }); + it('should verify that basic widgets work', function(){ + navigateTo('widgets.html'); + input('text.basic').enter('Carlos'); + expect(binding('text.basic')).toEqual('Carlos'); + pause(2); + input('text.basic').enter('Carlos Santana'); + pause(2); + expect(binding('text.basic')).toEqual('Carlos Santana'); + pause(2); + input('text.password').enter('secret'); + expect(binding('text.password')).toEqual('secret'); + expect(binding('text.hidden')).toEqual('hiddenValue'); + expect(binding('gender')).toEqual('male'); + pause(2); + input('gender').select('female'); + expect(binding('gender')).toEqual('female'); + pause(2); }); }); 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('

Current URL: None

'); +}; + +/** + * 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(''); + 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( + '' + + '
' + + '
' + + '
' + ); +}; + +/** + * 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( + '
  • ' + ); + 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( + '
    ' + + '

    ' + + '
    ' + + ' ' + + '
    ' + ); + 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( + '
    ' + + '

    ' + + ' ' + + ' ' + + '

    ' + + '
    ' + + '
      ' + + '
    ' + ); + 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('
  • '); + 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('
    ');
    +    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(
    +    '' +
    +    ''
    +  );
    +  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(
    -      '
    ' + - '
    ' + - '
    ' + - '
    ' + - '' + - '
    '); - 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(''); - parent.append(container); - } - var element = jQuery('
  • '); - 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( + '
    ' + + '
    ' + ); + 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(''); } @@ -18,26 +19,51 @@ document.write(''); } - 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( + '
    ' + + '
    ' + ); + 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(''); -})(window.onload); + addScript("SpecRunner.js"); + addScript("dsl.js"); + addScript("matchers.js"); + // Create the runner (which also sets up the global API) + document.write( + '' + ); + +})(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; +}); diff --git a/test/AngularSpec.js b/test/AngularSpec.js index e0228e16..6faed707 100644 --- a/test/AngularSpec.js +++ b/test/AngularSpec.js @@ -86,6 +86,10 @@ describe('equals', function(){ expect(equals({name:'misko'}, {name:'misko', $id:2})).toEqual(true); expect(equals({name:'misko', $id:1}, {name:'misko'})).toEqual(true); }); + + it('should ignore functions', function(){ + expect(equals({func: function() {}}, {bar: function() {}})).toEqual(true); + }); }); describe('parseKeyValue', function() { diff --git a/test/scenario/ApplicationSpec.js b/test/scenario/ApplicationSpec.js new file mode 100644 index 00000000..706fbc36 --- /dev/null +++ b/test/scenario/ApplicationSpec.js @@ -0,0 +1,75 @@ +describe('angular.scenario.Application', function() { + var app, frames; + + beforeEach(function() { + frames = _jQuery("
    "); + app = new angular.scenario.Application(frames); + }); + + it('should return new $window and $document after navigate', function() { + var testWindow, testDocument, counter = 0; + app.navigateTo = noop; + app.getWindow = function() { + return {x:counter++, document:{x:counter++}}; + }; + app.navigateTo('http://www.google.com/'); + app.executeAction(function($document, $window) { + testWindow = $window; + testDocument = $document; + }); + app.navigateTo('http://www.google.com/'); + app.executeAction(function($document, $window) { + expect($window).not.toEqual(testWindow); + expect($document).not.toEqual(testDocument); + }); + }); + + it('should execute callback on $window of frame', function() { + var testWindow = {document: {}}; + app.getWindow = function() { + return testWindow; + }; + app.executeAction(function($document, $window) { + expect(this).toEqual($window); + expect(this).toEqual(testWindow); + }); + }); + + it('should create a new iframe each time', function() { + app.navigateTo('about:blank'); + var frame = app.getFrame(); + frame.attr('test', true); + app.navigateTo('about:blank'); + expect(app.getFrame().attr('test')).toBeFalsy(); + }); + + it('should URL description bar', function() { + app.navigateTo('about:blank'); + var anchor = frames.find('> h2 a'); + expect(anchor.attr('href')).toEqual('about:blank'); + expect(anchor.text()).toEqual('about:blank'); + }); + + it('should call onload handler when frame loads', function() { + var called; + app.getFrame = function() { + // Mock a little jQuery + var result = { + remove: function() { + return result; + }, + attr: function(key, value) { + return (!value) ? 'attribute value' : result; + }, + load: function() { + called = true; + } + }; + return result; + }; + app.navigateTo('about:blank', function() { + called = true; + }); + expect(called).toBeTruthy(); + }); +}); diff --git a/test/scenario/DSLSpec.js b/test/scenario/DSLSpec.js index 7a8e2e3b..9b011847 100644 --- a/test/scenario/DSLSpec.js +++ b/test/scenario/DSLSpec.js @@ -1,181 +1,232 @@ -describe("DSL", function() { +/** + * Very basic Mock of angular. + */ +function AngularMock() { + this.reset(); + this.service = this; +} - var lastDocument, executeFuture, Expect; +AngularMock.prototype.reset = function() { + this.log = []; +}; - beforeEach(function() { - setUpContext(); - executeFuture = function(future, html, callback) { - lastDocument = _jQuery('
    ' + html + '
    '); - lastFrame = _jQuery(''); - _jQuery(document.body).append(lastDocument); - var specThis = { - testWindow: window, - testDocument: lastDocument, - testFrame: lastFrame, - jQuery: _jQuery - }; - future.behavior.call(specThis, callback || noop); - }; - Expect = _window.expect; - }); - - describe("input", function() { +AngularMock.prototype.element = function(node) { + this.log.push('element(' + node.nodeName.toLowerCase() + ')'); + return this; +}; - var input = angular.scenario.dsl.input; +AngularMock.prototype.trigger = function(value) { + this.log.push('element().trigger(' + value + ')'); +}; - it('should enter', function() { - var future = input('name').enter('John'); - expect(future.name).toEqual("input 'name' enter 'John'"); - executeFuture(future, ''); - expect(lastDocument.find('input').val()).toEqual('John'); - }); +AngularMock.prototype.$browser = function() { + this.log.push('$brower()'); + return this; +}; - it('should select', function() { - var future = input('gender').select('female'); - expect(future.name).toEqual("input 'gender' select 'female'"); - executeFuture(future, - '' + - ''); - expect(lastDocument.find(':radio:checked').length).toEqual(1); - expect(lastDocument.find(':radio:checked').val()).toEqual('female'); - }); - }); +AngularMock.prototype.poll = function() { + this.log.push('$brower.poll()'); + return this; +}; - describe('browser', function() { - var browser = angular.scenario.dsl.browser; - it('shoud return true if location with empty hash provided is same ' + - 'as location of the page', function() { - browser.location.href = "http://server"; - expect(browser.location.toEqual("http://server")).toEqual(true); - }); - it('shoud return true if location with hash provided is same ' + - 'as location of the page', function() { - browser.location.href = "http://server"; - browser.location.hash = "hashPath"; - expect(browser.location.toEqual("http://server/#/hashPath")).toEqual(true); - }); - it('should return true if the location provided is the same as which ' + - 'browser navigated to', function() { - var future = browser.navigateTo("http://server/#/hashPath"); - expect(future.name).toEqual("Navigate to: http://server/#/hashPath"); - executeFuture(future, ''); - expect(browser.location.toEqual("http://server/#/hashPath")).toEqual(true); - expect(browser.location.toEqual("http://server/")).toEqual(false); +AngularMock.prototype.notifyWhenNoOutstandingRequests = function(fn) { + this.log.push('$brower.notifyWhenNoOutstandingRequests()'); + fn(); +}; - future = browser.navigateTo("http://server/"); - expect(future.name).toEqual("Navigate to: http://server/"); - executeFuture(future, ''); - expect(browser.location.toEqual("http://server/")).toEqual(true); - }); +describe("angular.scenario.dsl", function() { + var $window; + var $root; + var application; + + beforeEach(function() { + $window = { + document: _jQuery("
    "), + angular: new AngularMock() + }; + $root = angular.scope({}, angular.service); + $root.futures = []; + $root.addFuture = function(name, fn) { + this.futures.push(name); + fn.call(this, function(error, result) { + $root.futureError = error; + $root.futureResult = result; + }); + }; + $root.application = new angular.scenario.Application($window.document); + $root.application.getWindow = function() { + return $window; + }; + $root.application.navigateTo = function(url, callback) { + $window.location = url; + callback(); + }; + // Just use the real one since it delegates to this.addFuture + $root.addFutureAction = angular.scenario. + SpecRunner.prototype.addFutureAction; }); - - describe('repeater', function() { - - var repeater = angular.scenario.dsl.repeater; - var html; + + describe('Pause', function() { beforeEach(function() { - html = "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "
    " + - "John Marston" + - "" + - "Red Dead Redemption" + - "
    " + - "Nathan Drake" + - "" + - "Uncharted" + - "
    "; + $root.setTimeout = function(fn, value) { + $root.timerValue = value; + fn(); + }; }); - it('should count', function() { - var future = repeater('.repeater-row').count(); - expect(future.name).toEqual("repeater '.repeater-row' count"); - executeFuture(future, - "
    a
    " + - "
    b
    ", - function(value) { - future.fulfill(value); - }); - expect(future.fulfilled).toBeTruthy(); - expect(future.value).toEqual(2); + + it('should pause for specified seconds', function() { + angular.scenario.dsl.pause.call($root).call($root, 10); + expect($root.timerValue).toEqual(10000); + expect($root.futureResult).toEqual(10000); + }); + }); + + describe('Expect', function() { + it('should chain and execute matcher', function() { + var future = {value: 10}; + var result = angular.scenario.dsl.expect.call($root).call($root, future); + result.toEqual(10); + expect($root.futureError).toBeUndefined(); + expect($root.futureResult).toBeUndefined(); + var result = angular.scenario.dsl.expect.call($root).call($root, future); + result.toEqual(20); + expect($root.futureError).toBeDefined(); }); - - function assertFutureState(future, expectedName, expectedValue) { - expect(future.name).toEqual(expectedName); - executeFuture(future, html, function(value) { - future.fulfill(value); - }); - expect(future.fulfilled).toBeTruthy(); - expect(future.value).toEqual(expectedValue); - } - it('should collect bindings', function() { - assertFutureState(repeater('.epic').collect('{{hero}}'), - "repeater '.epic' collect '{{hero}}'", - ['John Marston', 'Nathan Drake']); - assertFutureState(repeater('.epic').collect('{{game}}'), - "repeater '.epic' collect '{{game}}'", - ['Red Dead Redemption', 'Uncharted']); + }); + + describe('NavigateTo', function() { + it('should allow a string url', function() { + angular.scenario.dsl.navigateTo.call($root).call($root, 'http://myurl'); + expect($window.location).toEqual('http://myurl'); + expect($root.futureResult).toEqual('http://myurl'); + }); + + it('should allow a future url', function() { + var future = {name: 'future name', value: 'http://myurl'}; + angular.scenario.dsl.navigateTo.call($root).call($root, future); + expect($window.location).toEqual('http://myurl'); + expect($root.futureResult).toEqual('http://myurl'); }); - it('should collect normal selectors', function() { - assertFutureState(repeater('.epic').collect('.hero-name'), - "repeater '.epic' collect '.hero-name'", - ['John Marston', 'Nathan Drake']); - assertFutureState(repeater('.epic').collect('.game-name'), - "repeater '.epic' collect '.game-name'", - ['Red Dead Redemption', 'Uncharted']); + + it('should complete if angular is missing from app frame', function() { + delete $window.angular; + angular.scenario.dsl.navigateTo.call($root).call($root, 'http://myurl'); + expect($window.location).toEqual('http://myurl'); + expect($root.futureResult).toEqual('http://myurl'); }); - it('should collect normal attributes', function() { - //TODO(shyamseshadri) : Left as an exercise to the user + + it('should wait for angular notify when no requests pending', function() { + angular.scenario.dsl.navigateTo.call($root).call($root, 'url'); + expect($window.angular.log).toContain('$brower.poll()'); + expect($window.angular.log) + .toContain('$brower.notifyWhenNoOutstandingRequests()'); }); }); - - describe('element', function() { - var element = angular.scenario.dsl.element; - var html; + + describe('Element Finding', function() { + var doc; + //TODO(esprehn): Work around a bug in jQuery where attribute selectors + // only work if they are executed on a real document, not an element. + // + // ex. jQuery('#foo').find('[name="bar"]') // fails + // ex. jQuery('#foo [name="bar"]') // works, wtf? + // beforeEach(function() { - html = '
    ' + - '
    ' + - 'Description : ' + - 'Details...' + - '' + - 'Date created: ' + - '01/01/01' + - '' + - '
    ' + - '
    '; + doc = _jQuery('
    '); + _jQuery(document.body).append(doc); + $window.document = window.document; }); - function timeTravel(future) { - executeFuture(future, html, function(value) { future.fulfill(value); }); - expect(future.fulfilled).toBeTruthy(); - } - it('should find elements on the page and provide jquery api', function() { - var future = element('.reports-detail').text(); - expect(future.name).toEqual("Element '.reports-detail'.text()"); - timeTravel(future); - expect(future.value). - toEqual('Description : Details...Date created: 01/01/01'); -// expect(future.value.find('.desc').text()). -// toEqual('Description : Details...'); + + afterEach(function() { + _jQuery(document.body) + .find('#angular-scenario-binding') + .remove(); }); - it('should find elements with angular syntax', function() { - var future = element('{{report.description}}').text(); - expect(future.name).toEqual("Element '{{report.description}}'.text()"); - timeTravel(future); - expect(future.value).toEqual('Details...'); -// expect(future.value.attr('ng:bind')).toEqual('report.description'); + + describe('Binding', function() { + it('should select binding by name', function() { + doc.append('some value'); + angular.scenario.dsl.binding.call($root).call($root, 'foo.bar'); + expect($root.futureResult).toEqual('some value'); + }); + + it('should return error if no binding exists', function() { + angular.scenario.dsl.binding.call($root).call($root, 'foo.bar'); + expect($root.futureError).toMatch(/does not exist/); + }); }); - it('should be able to click elements', function(){ - var future = element('.link-class').click(); - expect(future.name).toEqual("Element '.link-class'.click()"); - executeFuture(future, html, function(value) { future.fulfill(value); }); - expect(future.fulfilled).toBeTruthy(); - // TODO(rajat): look for some side effect from click happening? + + describe('Input', function() { + it('should change value in text input', function() { + doc.append(''); + var chain = angular.scenario.dsl.input + .call($root).call($root, 'test.input'); + chain.enter('foo'); + expect($window.angular.log).toContain('element(input)'); + expect($window.angular.log).toContain('element().trigger(change)'); + expect(_jQuery('input[name="test.input"]').val()).toEqual('foo'); + }); + + it('should return error if no input exists', function() { + var chain = angular.scenario.dsl.input + .call($root).call($root, 'test.input'); + chain.enter('foo'); + expect($root.futureError).toMatch(/does not exist/); + }); + + it('should toggle checkbox state', function() { + doc.append(''); + expect(_jQuery('input[name="test.input"]') + .attr('checked')).toBeTruthy(); + var chain = angular.scenario.dsl.input + .call($root).call($root, 'test.input'); + chain.check(); + expect($window.angular.log).toContain('element(input)'); + expect($window.angular.log).toContain('element().trigger(click)'); + expect(_jQuery('input[name="test.input"]') + .attr('checked')).toBeFalsy(); + $window.angular.reset(); + chain.check(); + expect($window.angular.log).toContain('element(input)'); + expect($window.angular.log).toContain('element().trigger(click)'); + expect(_jQuery('input[name="test.input"]') + .attr('checked')).toBeTruthy(); + }); + + it('should return error if checkbox does not exist', function() { + var chain = angular.scenario.dsl.input + .call($root).call($root, 'test.input'); + chain.check(); + expect($root.futureError).toMatch(/does not exist/); + }); + + it('should select option from radio group', function() { + doc.append( + '' + + '' + ); + expect(_jQuery('input[name="0@test.input"][value="bar"]') + .attr('checked')).toBeTruthy(); + expect(_jQuery('input[name="0@test.input"][value="foo"]') + .attr('checked')).toBeFalsy(); + var chain = angular.scenario.dsl.input + .call($root).call($root, 'test.input'); + chain.select('foo'); + expect($window.angular.log).toContain('element(input)'); + expect($window.angular.log).toContain('element().trigger(click)'); + expect(_jQuery('input[name="0@test.input"][value="bar"]') + .attr('checked')).toBeFalsy(); + expect(_jQuery('input[name="0@test.input"][value="foo"]') + .attr('checked')).toBeTruthy(); + }); + + it('should return error if radio button does not exist', function() { + var chain = angular.scenario.dsl.input + .call($root).call($root, 'test.input'); + chain.select('foo'); + expect($root.futureError).toMatch(/does not exist/); + }); }); }); + }); diff --git a/test/scenario/DescribeSpec.js b/test/scenario/DescribeSpec.js new file mode 100644 index 00000000..05129cfe --- /dev/null +++ b/test/scenario/DescribeSpec.js @@ -0,0 +1,85 @@ +describe('angular.scenario.Describe', function() { + var log; + var root; + + beforeEach(function() { + root = new angular.scenario.Describe(); + + /** + * Simple callback logging system. Use to assert proper order of calls. + */ + log = function(text) { + log.text = log.text + text; + }; + log.fn = function(text) { + return function(done){ + log(text); + (done || angular.noop)(); + }; + }; + log.reset = function() { + log.text = ''; + }; + log.reset(); + }); + + it('should handle basic nested case', function() { + root.describe('A', function(){ + this.beforeEach(log.fn('{')); + this.afterEach(log.fn('}')); + this.it('1', log.fn('1')); + this.describe('B', function(){ + this.beforeEach(log.fn('(')); + this.afterEach(log.fn(')')); + this.it('2', log.fn('2')); + }); + }); + var specs = root.getSpecs(); + expect(specs.length).toEqual(2); + + expect(specs[0].name).toEqual('2'); + specs[0].fn(); + expect(log.text).toEqual('{(2)}'); + + log.reset(); + expect(specs[1].name).toEqual('1'); + specs[1].fn(); + expect(log.text).toEqual('{1}'); + }); + + it('should link nested describe blocks with parent and children', function() { + root.describe('A', function() { + this.it('1', angular.noop); + this.describe('B', function() { + this.it('2', angular.noop); + this.describe('C', function() { + this.it('3', angular.noop); + }); + }); + }); + var specs = root.getSpecs(); + expect(specs[2].definition.parent).toEqual(root); + expect(specs[0].definition.parent).toEqual(specs[2].definition.children[0]); + }); + + it('should not process xit and xdescribe', function() { + root.describe('A', function() { + this.xit('1', angular.noop); + this.xdescribe('B', function() { + this.it('2', angular.noop); + this.describe('C', function() { + this.it('3', angular.noop); + }); + }); + }); + var specs = root.getSpecs(); + expect(specs.length).toEqual(0); + }); + + it('should create uniqueIds in the tree', function() { + angular.scenario.Describe.id = 0; + var a = new angular.scenario.Describe(); + var b = new angular.scenario.Describe(); + expect(a.id).toNotEqual(b.id); + }); +}); diff --git a/test/scenario/FutureSpec.js b/test/scenario/FutureSpec.js new file mode 100644 index 00000000..ae475779 --- /dev/null +++ b/test/scenario/FutureSpec.js @@ -0,0 +1,38 @@ +describe('angular.scenario.Future', function() { + var future; + + it('should set the name and behavior', function() { + var behavior = function() {}; + var future = new angular.scenario.Future('test name', behavior); + expect(future.name).toEqual('test name'); + expect(future.behavior).toEqual(behavior); + expect(future.value).toBeUndefined(); + expect(future.fulfilled).toBeFalsy(); + }); + + it('should be fulfilled after execution and done callback', function() { + var future = new angular.scenario.Future('test name', function(done) { + done(); + }); + future.execute(angular.noop); + expect(future.fulfilled).toBeTruthy(); + }); + + it('should take callback with (error, result) and forward', function() { + var future = new angular.scenario.Future('test name', function(done) { + done(10, 20); + }); + future.execute(function(error, result) { + expect(error).toEqual(10); + expect(result).toEqual(20); + }); + }); + + it('should use error as value if provided', function() { + var future = new angular.scenario.Future('test name', function(done) { + done(10, 20); + }); + future.execute(angular.noop); + expect(future.value).toEqual(10); + }); +}); diff --git a/test/scenario/HtmlUISpec.js b/test/scenario/HtmlUISpec.js new file mode 100644 index 00000000..b2e2652f --- /dev/null +++ b/test/scenario/HtmlUISpec.js @@ -0,0 +1,87 @@ +describe('angular.scenario.HtmlUI', function() { + var ui; + var context; + var spec; + + beforeEach(function() { + spec = { + name: 'test spec', + definition: { + id: 10, + name: 'child', + children: [], + parent: { + id: 20, + name: 'parent', + children: [] + } + } + }; + context = _jQuery("
    "); + ui = new angular.scenario.ui.Html(context); + }); + + it('should create nested describe context', function() { + ui.addSpec(spec); + expect(context.find('#describe-20 #describe-10 > h2').text()) + .toEqual('describe: child'); + expect(context.find('#describe-20 > h2').text()).toEqual('describe: parent'); + expect(context.find('#describe-10 .tests > li .test-info .test-name').text()) + .toEqual('it test spec'); + expect(context.find('#describe-10 .tests > li').hasClass('status-pending')) + .toBeTruthy(); + }); + + it('should update totals when steps complete', function() { + // Error + ui.addSpec(spec).error('error'); + // Error + specUI = ui.addSpec(spec); + specUI.addStep('some step').finish(); + specUI.finish('error'); + // Failure + specUI = ui.addSpec(spec); + specUI.addStep('some step').finish('failure'); + specUI.finish('failure'); + // Failure + specUI = ui.addSpec(spec); + specUI.addStep('some step').finish('failure'); + specUI.finish('failure'); + // Failure + specUI = ui.addSpec(spec); + specUI.addStep('some step').finish('failure'); + specUI.finish('failure'); + // Success + specUI = ui.addSpec(spec); + specUI.addStep('some step').finish(); + specUI.finish(); + + expect(parseInt(context.find('#status-legend .status-failure').text())) + .toEqual(3); + expect(parseInt(context.find('#status-legend .status-error').text())) + .toEqual(2); + expect(parseInt(context.find('#status-legend .status-success').text())) + .toEqual(1); + }); + + it('should update timer when test completes', function() { + // Success + specUI = ui.addSpec(spec); + specUI.addStep('some step').finish(); + specUI.finish(); + + // Failure + specUI = ui.addSpec(spec); + specUI.addStep('some step').finish('failure'); + specUI.finish('failure'); + + // Error + specUI = ui.addSpec(spec).error('error'); + + context.find('#describe-10 .tests > li .test-info .timer-result') + .each(function(index, timer) { + expect(timer.innerHTML).toMatch(/ms$/); + }); + }); + +}); diff --git a/test/scenario/MatcherSpec.js b/test/scenario/MatcherSpec.js deleted file mode 100644 index 2eddd2bc..00000000 --- a/test/scenario/MatcherSpec.js +++ /dev/null @@ -1,38 +0,0 @@ -describe('Matcher', function () { - function executeFutures() { - for(var i in $scenario.currentSpec.futures) { - var future = $scenario.currentSpec.futures[i]; - future.behavior.call({}, function(value) { future.fulfill(value); }); - } - } - var matcher; - beforeEach(function() { - setUpContext(); - var future = $scenario.addFuture('Calculate first future', function(done) { - done(123); - }); - matcher = new Matcher(this, future); - - }); - it('should correctly match toEqual', function() { - matcher.toEqual(123); - executeFutures(); - }); - it('should throw an error when incorrect match toEqual', function() { - matcher.toEqual(456); - try { - executeFutures(); - fail(); - } catch (e) { - expect(e).toEqual('Expected 456 but was 123'); - } - }); - it('should correctly match arrays', function() { - var future = $scenario.addFuture('Calculate first future', function(done) { - done(['a', 'b']); - }); - matcher = new Matcher(this, future); - matcher.toEqual(['a', 'b']); - executeFutures(); - }); -}); \ No newline at end of file diff --git a/test/scenario/RunnerSpec.js b/test/scenario/RunnerSpec.js index 2986add6..43d97257 100644 --- a/test/scenario/RunnerSpec.js +++ b/test/scenario/RunnerSpec.js @@ -1,238 +1,96 @@ -describe('Runner', function() { - - var Describe, It, BeforeEach, AfterEach, body; - +/** + * Mock spec runner. + */ +function MockSpecRunner() {} +MockSpecRunner.prototype.run = function(ui, spec, specDone) { + spec.fn.call(this); + specDone(); +}; + +describe('angular.scenario.Runner', function() { + var $window; + var runner; + beforeEach(function() { - setUpContext(); - Describe = _window.describe; - It = _window.it; - BeforeEach = _window.beforeEach; - AfterEach = _window.afterEach; - body = _jQuery('
    '); - }); - - describe('describe', function() { - it('should consume the describe functions', function() { - Describe('describe name', logger('body')); - expect(log).toEqual('body'); + // Trick to get the scope out of a DSL statement + angular.scenario.dsl('dslScope', function() { + var scope = this; + return function() { return scope; }; }); - - describe('it', function() { - it('should consume it', function() { - Describe('describe name', function() { - It('should text', logger('body')); - }); - expect(log).toEqual('body'); - var spec = $scenario.specs['describe name: it should text']; - expect(spec.futures).toEqual([]); - expect(spec.name).toEqual('describe name: it should text'); - }); - - it('should complain on duplicate it', function() { - // WRITE ME!!!! - }); - - it('should create a failing future if there is a javascript error', function() { - var spec; - Describe('D1', function() { - It('I1', function() { - spec = $scenario.currentSpec; - throw {message: 'blah'}; - }); - }); - var future = spec.futures[0]; - expect(future.name).toEqual('blah'); - try { - future.behavior(); - fail(); - } catch (e) { - expect(e.message).toEqual('blah'); - } - }); - }); - - describe('beforeEach', function() { - it('should execute beforeEach before every it', function() { - Describe('describe name', function() { - BeforeEach(logger('before;')); - It('should text', logger('body;')); - It('should text2', logger('body2;')); - }); - expect(log).toEqual('before;body;before;body2;'); - }); + // Trick to get the scope out of a DSL statement + angular.scenario.dsl('dslChain', function() { + return function() { + this.chained = 0; + this.chain = function() { this.chained++; return this; }; + return this; + }; }); - describe('afterEach', function() { - it('should execute afterEach after every it', function() { - Describe('describe name', function() { - AfterEach(logger('after;')); - It('should text1', logger('body1;')); - It('should text2', logger('body2;')); - }); - expect(log).toEqual('body1;after;body2;after;'); - }); - - it('should always execute afterEach after every it', function() { - Describe('describe name', function() { - AfterEach(logger('after;')); - It('should text', function() { - logger('body1;')(); - throw "MyError"; - }); - It('should text2', logger('body2;')); - }); - expect(log).toEqual('body1;after;body2;after;'); - }); - - it('should report an error if afterEach fails', function() { - var next; - Describe('describe name', function() { - AfterEach(function() { - $scenario.addFuture('afterEachLog', logger('after;')); - $scenario.addFuture('afterEachThrow', function() { - throw "AfterError"; - }); - }); - It('should text1', function() { - $scenario.addFuture('future1', logger('future1;')); - }); - It('should text2', function() { - $scenario.addFuture('future2', logger('future2;')); - }); - }); - $scenario.run(body); - expect(log).toEqual('future1;after;future2;after;'); - expect(_window.$testrun.results).toEqual([ - { name : 'describe name: it should text1', - passed : false, - error : 'AfterError', - steps : [ 'future1', 'afterEachLog', 'afterEachThrow' ] }, - { name : 'describe name: it should text2', - passed : false, - error : 'AfterError', - steps : [ 'future2', 'afterEachLog', 'afterEachThrow' ] }]); - }); + $window = {}; + runner = new angular.scenario.Runner($window); + }); + + afterEach(function() { + delete angular.scenario.dsl.dslScope; + delete angular.scenario.dsl.dslChain; + }); + + it('should publish the functions in the public API', function() { + angular.foreach(runner.api, function(fn, name) { + var func; + if (name in $window) { + func = $window[name]; + } + expect(angular.isFunction(func)).toBeTruthy(); }); }); - - describe('future building', function() { - it('should queue futures', function() { - function behavior(){} - Describe('name', function() { - It('should', function() { - $scenario.addFuture('futureName', behavior); + + it('should construct valid describe trees with public API', function() { + var before = []; + var after = []; + $window.describe('A', function() { + $window.beforeEach(function() { before.push('A'); }); + $window.afterEach(function() { after.push('A'); }); + $window.it('1', angular.noop); + $window.describe('B', function() { + $window.beforeEach(function() { before.push('B'); }); + $window.afterEach(function() { after.push('B'); }); + $window.it('2', angular.noop); + $window.describe('C', function() { + $window.beforeEach(function() { before.push('C'); }); + $window.afterEach(function() { after.push('C'); }); + $window.it('3', angular.noop); }); }); - expect($scenario.specs['name: it should'].futures[0].name). - toEqual('futureName'); }); + var specs = runner.rootDescribe.getSpecs(); + specs[0].fn(); + expect(before).toEqual(['A', 'B', 'C']); + expect(after).toEqual(['C', 'B', 'A']); + expect(specs[2].definition.parent).toEqual(runner.rootDescribe); + expect(specs[0].definition.parent).toEqual(specs[2].definition.children[0]); }); - - describe('execution', function() { - it('should execute the queued futures', function() { - var next, firstThis, secondThis, doneThis, spec; - $scenario.specs['spec'] = { - futures: [ - new Future('future1', function(done) { - next = done; - log += 'first;'; - firstThis = this; - }), - new Future('future2', function(done) { - next = done; - log += 'second;'; - secondThis = this; - }) - ] - }; - - spec = $scenario.execute('spec', function(done){ - log += 'done;'; - doneThis = this; + + it('should publish the DSL statements to the $window', function() { + $window.describe('describe', function() { + $window.it('1', function() { + expect($window.dslScope).toBeDefined(); }); - expect(log).toEqual('first;'); - next(); - expect(log).toEqual('first;second;'); - next(); - expect(log).toEqual('first;second;done;'); - expect(spec === window).toEqual(false); - expect(spec).toEqual(firstThis); - expect(spec).toEqual(secondThis); - expect(spec).toEqual(doneThis); - - expect(spec.result.failed).toEqual(false); - expect(spec.result.finished).toEqual(true); - expect(spec.result.error).toBeUndefined(); - expect(spec.result.passed).toEqual(true); - }); - - it('should handle exceptions in a future', function() { - $scenario.specs['spec'] = { - futures: [ - new Future('first future', function(done) { - done(); - }), - new Future('error', function(done) { - throw "MyError"; - }), - new Future('should not execute', function(done) { - done(); - }) - ] - }; - - var spec = $scenario.execute('spec'); - - expect(spec.result.passed).toEqual(false); - expect(spec.result.failed).toEqual(true); - expect(spec.result.finished).toEqual(true); - expect(spec.result.error).toEqual("MyError"); - expect(_window.$testrun.results).toEqual([{ - name: 'spec', - passed: false, - error: 'MyError', - steps: ['first future', 'error']}]); }); + runner.run(null/*ui*/, null/*application*/, MockSpecRunner, rethrow); }); - - describe('run', function() { - var next; - beforeEach(function() { - Describe('d1', function() { - It('it1', function() { $scenario.addFuture('s1', logger('s1,')); }); - It('it2', function() { - $scenario.addFuture('s2', logger('s2,')); - $scenario.addFuture('s2.2', function(done){ next = done; }); - }); + + it('should create a new scope for each DSL chain', function() { + $window.describe('describe', function() { + $window.it('1', function() { + var scope = $window.dslScope(); + scope.test = "foo"; + expect($window.dslScope().test).toBeUndefined(); }); - Describe('d2', function() { - It('it3', function() { $scenario.addFuture('s3', logger('s3,')); }); - It('it4', function() { $scenario.addFuture('s4', logger('s4,')); }); + $window.it('2', function() { + var scope = $window.dslChain().chain().chain(); + expect(scope.chained).toEqual(2); }); }); - it('should execute all specs', function() { - $scenario.run(body); - - expect(log).toEqual('s1,s2,'); - next(); - expect(log).toEqual('s1,s2,s3,s4,'); - }); - it('should publish done state and results as tests are run', function() { - expect(_window.$testrun.done).toBeFalsy(); - expect(_window.$testrun.results).toEqual([]); - $scenario.run(body); - expect(_window.$testrun.done).toBeFalsy(); - expect(_window.$testrun.results).toEqual([ - {name: 'd1: it it1', passed: true, error: undefined, steps: ['s1']} - ]); - next(); - expect(_window.$testrun.done).toBeTruthy(); - expect(_window.$testrun.results).toEqual([ - {name: 'd1: it it1', passed: true, error: undefined, steps: ['s1']}, - {name: 'd1: it it2', passed: true, error: undefined, steps: ['s2', 's2.2']}, - {name: 'd2: it it3', passed: true, error: undefined, steps: ['s3']}, - {name: 'd2: it it4', passed: true, error: undefined, steps: ['s4']} - ]); - }); + runner.run(null/*ui*/, null/*application*/, MockSpecRunner, rethrow); }); - -}); \ No newline at end of file +}); diff --git a/test/scenario/SpecRunnerSpec.js b/test/scenario/SpecRunnerSpec.js new file mode 100644 index 00000000..81b66956 --- /dev/null +++ b/test/scenario/SpecRunnerSpec.js @@ -0,0 +1,165 @@ +/** + * Mock of all required UI classes/methods. (UI, Spec, Step). + */ +function UIMock() { + this.log = []; +} +UIMock.prototype = { + addSpec: function(spec) { + var log = this.log; + log.push('addSpec:' + spec.name); + return { + addStep: function(name) { + log.push('addStep:' + name); + return { + finish: function(e) { + log.push('step finish:' + (e ? e : '')); + return this; + }, + error: function(e) { + log.push('step error:' + (e ? e : '')); + return this; + } + }; + }, + finish: function(e) { + log.push('spec finish:' + (e ? e : '')); + return this; + }, + error: function(e) { + log.push('spec error:' + (e ? e : '')); + return this; + } + }; + }, +}; + +/** + * Mock Application + */ +function ApplicationMock($window) { + this.$window = $window; +} +ApplicationMock.prototype = { + executeAction: function(callback) { + callback.call(this.$window); + } +}; + +describe('angular.scenario.SpecRunner', function() { + var $window; + var runner; + + beforeEach(function() { + $window = {}; + runner = angular.scope(); + runner.application = new ApplicationMock($window); + runner.$become(angular.scenario.SpecRunner); + }); + + it('should bind futures to the spec', function() { + runner.addFuture('test future', function(done) { + this.application.value = 10; + done(); + }); + runner.futures[0].execute(angular.noop); + expect(runner.application.value).toEqual(10); + }); + + it('should pass done to future action behavior', function() { + runner.addFutureAction('test future', function(done) { + expect(angular.isFunction(done)).toBeTruthy(); + done(10, 20); + }); + runner.futures[0].execute(function(error, result) { + expect(error).toEqual(10); + expect(result).toEqual(20); + }); + }); + + it('should pass execute future action on the $window', function() { + runner.addFutureAction('test future', function(done) { + this.test = 'test value'; + done(); + }); + runner.futures[0].execute(angular.noop); + expect($window.test).toEqual('test value'); + }); + + it('should execute spec function and notify UI', function() { + var finished = false; + var ui = new UIMock(); + var spec = {name: 'test spec', fn: function() { + this.test = 'some value'; + }}; + runner.addFuture('test future', function(done) { + done(); + }); + runner.run(ui, spec, function() { + finished = true; + }); + expect(runner.test).toEqual('some value'); + expect(finished).toBeTruthy(); + expect(ui.log).toEqual([ + 'addSpec:test spec', + 'addStep:test future', + 'step finish:', + 'spec finish:' + ]); + }); + + it('should execute notify UI on spec setup error', function() { + var finished = false; + var ui = new UIMock(); + var spec = {name: 'test spec', fn: function() { + throw 'message'; + }}; + runner.run(ui, spec, function() { + finished = true; + }); + expect(finished).toBeTruthy(); + expect(ui.log).toEqual([ + 'addSpec:test spec', + 'spec error:message' + ]); + }); + + it('should execute notify UI on step failure', function() { + var finished = false; + var ui = new UIMock(); + var spec = {name: 'test spec', fn: angular.noop}; + runner.addFuture('test future', function(done) { + done('failure message'); + }); + runner.run(ui, spec, function() { + finished = true; + }); + expect(finished).toBeTruthy(); + expect(ui.log).toEqual([ + 'addSpec:test spec', + 'addStep:test future', + 'step finish:failure message', + 'spec finish:failure message' + ]); + }); + + it('should execute notify UI on step error', function() { + var finished = false; + var ui = new UIMock(); + var spec = {name: 'test spec', fn: angular.noop}; + runner.addFuture('test future', function(done) { + throw 'error message'; + }); + runner.run(ui, spec, function() { + finished = true; + }); + expect(finished).toBeTruthy(); + expect(ui.log).toEqual([ + 'addSpec:test spec', + 'addStep:test future', + 'step error:error message', + 'spec finish:error message' + ]); + }); + +}); diff --git a/test/scenario/TestContext.js b/test/scenario/TestContext.js deleted file mode 100644 index 0c8e6143..00000000 --- a/test/scenario/TestContext.js +++ /dev/null @@ -1,15 +0,0 @@ -var _window, runner, log, $scenario; - -function logger(text) { - return function(done){ - log += text; - (done||noop)(); - }; -} - -function setUpContext() { - _window = {}; - runner = new angular.scenario.Runner(_window, _jQuery); - $scenario = _window.$scenario; - log = ''; -} diff --git a/test/scenario/matchersSpec.js b/test/scenario/matchersSpec.js new file mode 100644 index 00000000..faabd1a2 --- /dev/null +++ b/test/scenario/matchersSpec.js @@ -0,0 +1,43 @@ +describe('angular.scenario.matchers', function () { + var matchers; + + function expectMatcher(value, test) { + delete matchers.error; + delete matchers.future.value; + if (value !== undefined) { + matchers.future.value = value; + } + test(); + expect(matchers.error).toBeUndefined(); + } + + beforeEach(function() { + /** + * Mock up the future system wrapped around matchers. + * + * @see Scenario.js#angular.scenario.matcher + */ + matchers = { + future: { name: 'test' } + }; + matchers.addFuture = function(name, callback) { + callback(function(error) { + matchers.error = error; + }); + }; + angular.extend(matchers, angular.scenario.matcher); + }); + + it('should handle basic matching', function() { + expectMatcher(10, function() { matchers.toEqual(10); }); + expectMatcher('value', function() { matchers.toBeDefined(); }); + expectMatcher([1], function() { matchers.toBeTruthy(); }); + expectMatcher("", function() { matchers.toBeFalsy(); }); + expectMatcher(0, function() { matchers.toBeFalsy(); }); + expectMatcher('foo', function() { matchers.toMatch('.o.'); }); + expectMatcher(null, function() { matchers.toBeNull(); }); + expectMatcher([1, 2, 3], function() { matchers.toContain(2); }); + expectMatcher(3, function() { matchers.toBeLessThan(10); }); + expectMatcher(3, function() { matchers.toBeGreaterThan(-5); }); + }); +}); diff --git a/test/testabilityPatch.js b/test/testabilityPatch.js index 955dccfa..47bc0d0d 100644 --- a/test/testabilityPatch.js +++ b/test/testabilityPatch.js @@ -22,6 +22,19 @@ beforeEach(function(){ return "Expected to not have class 'ng-validation-error' but found."; }; return !hasClass; + }, + + toEqualData: function(expected) { + return equals(this.actual, expected); + }, + + toHaveClass: function(clazz) { + this.message = function(){ + return "Expected '" + sortedHtml(this.actual) + "' to have class '" + clazz + "'."; + }; + return this.actual.hasClass ? + this.actual.hasClass(clazz) : + jqLite(this.actual).hasClass(clazz); } }); }); @@ -194,3 +207,9 @@ function click(element) { JQLite.prototype.trigger.call(element, 'click'); } } + +function rethrow(e) { + if(e) { + throw e; + } +} -- cgit v1.2.3