diff options
| author | Misko Hevery | 2012-03-23 14:03:24 -0700 | 
|---|---|---|
| committer | Misko Hevery | 2012-03-28 11:16:35 -0700 | 
| commit | 2430f52bb97fa9d682e5f028c977c5bf94c5ec38 (patch) | |
| tree | e7529b741d70199f36d52090b430510bad07f233 /src/ngScenario | |
| parent | 944098a4e0f753f06b40c73ca3e79991cec6c2e2 (diff) | |
| download | angular.js-2430f52bb97fa9d682e5f028c977c5bf94c5ec38.tar.bz2 | |
chore(module): move files around in preparation for more modules
Diffstat (limited to 'src/ngScenario')
| -rw-r--r-- | src/ngScenario/Application.js | 102 | ||||
| -rw-r--r-- | src/ngScenario/Describe.js | 155 | ||||
| -rw-r--r-- | src/ngScenario/Future.js | 64 | ||||
| -rw-r--r-- | src/ngScenario/ObjectModel.js | 247 | ||||
| -rw-r--r-- | src/ngScenario/Runner.js | 227 | ||||
| -rw-r--r-- | src/ngScenario/Scenario.js | 417 | ||||
| -rw-r--r-- | src/ngScenario/SpecRunner.js | 145 | ||||
| -rw-r--r-- | src/ngScenario/angular-bootstrap.js | 60 | ||||
| -rw-r--r-- | src/ngScenario/angular.prefix | 7 | ||||
| -rw-r--r-- | src/ngScenario/angular.suffix | 22 | ||||
| -rw-r--r-- | src/ngScenario/dsl.js | 405 | ||||
| -rw-r--r-- | src/ngScenario/jstd-scenario-adapter/Adapter.js | 177 | ||||
| -rw-r--r-- | src/ngScenario/jstd-scenario-adapter/angular.prefix | 6 | ||||
| -rw-r--r-- | src/ngScenario/jstd-scenario-adapter/angular.suffix | 2 | ||||
| -rw-r--r-- | src/ngScenario/matchers.js | 45 | ||||
| -rw-r--r-- | src/ngScenario/output/Html.js | 171 | ||||
| -rw-r--r-- | src/ngScenario/output/Json.js | 10 | ||||
| -rw-r--r-- | src/ngScenario/output/Object.js | 8 | ||||
| -rw-r--r-- | src/ngScenario/output/Xml.js | 51 | 
19 files changed, 2321 insertions, 0 deletions
diff --git a/src/ngScenario/Application.js b/src/ngScenario/Application.js new file mode 100644 index 00000000..d3a70569 --- /dev/null +++ b/src/ngScenario/Application.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Represents the application currently being tested and abstracts usage + * of iframes or separate windows. + * + * @param {Object} context jQuery wrapper around HTML context. + */ +angular.scenario.Application = function(context) { +  this.context = context; +  context.append( +    '<h2>Current URL: <a href="about:blank">None</a></h2>' + +    '<div id="test-frames"></div>' +  ); +}; + +/** + * Gets the jQuery collection of frames. Don't use this directly because + * frames may go stale. + * + * @private + * @return {Object} jQuery collection + */ +angular.scenario.Application.prototype.getFrame_ = function() { +  return this.context.find('#test-frames iframe:last'); +}; + +/** + * Gets the window of the test runner frame. Always favor executeAction() + * instead of this method since it prevents you from getting a stale window. + * + * @private + * @return {Object} the window of the frame + */ +angular.scenario.Application.prototype.getWindow_ = function() { +  var contentWindow = this.getFrame_().prop('contentWindow'); +  if (!contentWindow) +    throw 'Frame window is not accessible.'; +  return contentWindow; +}; + +/** + * Changes the location of the frame. + * + * @param {string} url The URL. If it begins with a # then only the + *   hash of the page is changed. + * @param {function()} loadFn function($window, $document) Called when frame loads. + * @param {function()} errorFn function(error) Called if any error when loading. + */ +angular.scenario.Application.prototype.navigateTo = function(url, loadFn, errorFn) { +  var self = this; +  var frame = this.getFrame_(); +  //TODO(esprehn): Refactor to use rethrow() +  errorFn = errorFn || function(e) { throw e; }; +  if (url === 'about:blank') { +    errorFn('Sandbox Error: Navigating to about:blank is not allowed.'); +  } else if (url.charAt(0) === '#') { +    url = frame.attr('src').split('#')[0] + url; +    frame.attr('src', url); +    this.executeAction(loadFn); +  } else { +    frame.remove(); +    this.context.find('#test-frames').append('<iframe>'); +    frame = this.getFrame_(); +    frame.load(function() { +      frame.unbind(); +      try { +        self.executeAction(loadFn); +      } catch (e) { +        errorFn(e); +      } +    }).attr('src', url); +  } +  this.context.find('> h2 a').attr('href', url).text(url); +}; + +/** + * Executes a function in the context of the tested application. Will wait + * for all pending angular xhr requests before executing. + * + * @param {function()} action The callback to execute. function($window, $document) + *  $document is a jQuery wrapped document. + */ +angular.scenario.Application.prototype.executeAction = function(action) { +  var self = this; +  var $window = this.getWindow_(); +  if (!$window.document) { +    throw 'Sandbox Error: Application document not accessible.'; +  } +  if (!$window.angular) { +    return action.call(this, $window, _jQuery($window.document)); +  } +  angularInit($window.document, function(element) { +    element = $window.angular.element(element); +    var $injector = element.inheritedData('$injector'); +    $injector.invoke(function($browser){ +      $browser.notifyWhenNoOutstandingRequests(function() { +        action.call(self, $window, _jQuery($window.document)); +      }); +    }); +  }); +}; diff --git a/src/ngScenario/Describe.js b/src/ngScenario/Describe.js new file mode 100644 index 00000000..4d52e9d5 --- /dev/null +++ b/src/ngScenario/Describe.js @@ -0,0 +1,155 @@ +'use strict'; + +/** + * The representation of define blocks. Don't used directly, instead use + * define() in your tests. + * + * @param {string} descName Name of the block + * @param {Object} parent describe or undefined if the root. + */ +angular.scenario.Describe = function(descName, parent) { +  this.only = parent && parent.only; +  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; + +// Shared Unique ID generator for every it (spec) +angular.scenario.Describe.specId = 0; + +/** + * Defines a block to execute before each it or nested describe. + * + * @param {function()} body 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 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 Name of the block. Appended to the parent block's name. + * @param {function()} body 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); +}; + +/** + * Same as describe() but makes ddescribe blocks the only to run. + * + * @param {string} name Name of the test. + * @param {function()} body Body of the block. + */ +angular.scenario.Describe.prototype.ddescribe = function(name, body) { +  var child = new angular.scenario.Describe(name, this); +  child.only = true; +  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 Name of the test. + * @param {function()} vody Body of the block. + */ +angular.scenario.Describe.prototype.it = function(name, body) { +  this.its.push({ +    id: angular.scenario.Describe.specId++, +    definition: this, +    only: this.only, +    name: name, +    before: this.setupBefore, +    body: body, +    after: this.setupAfter +  }); +}; + +/** + * Same as it() but makes iit tests the only test to run. + * + * @param {string} name Name of the test. + * @param {function()} body Body of the block. + */ +angular.scenario.Describe.prototype.iit = function(name, body) { +  this.it.apply(this, arguments); +  this.its[this.its.length-1].only = true; +}; + +/** + * 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. + * + * @return {Array<Object>} Array of it blocks { + *   definition : Object // parent Describe + *   only: boolean + *   name: string + *   before: Function + *   body: Function + *   after: Function + *  } + */ +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); +  }); +  var only = []; +  angular.forEach(specs, function(it) { +    if (it.only) { +      only.push(it); +    } +  }); +  return (only.length && only) || specs; +}; diff --git a/src/ngScenario/Future.js b/src/ngScenario/Future.js new file mode 100644 index 00000000..335dd2bb --- /dev/null +++ b/src/ngScenario/Future.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * A future action in a spec. + * + * @param {string} name of the future action + * @param {function()} future callback(error, result) + * @param {function()} Optional. function that returns the file/line number. + */ +angular.scenario.Future = function(name, behavior, line) { +  this.name = name; +  this.behavior = behavior; +  this.fulfilled = false; +  this.value = undefined; +  this.parser = angular.identity; +  this.line = line || function() { return ''; }; +}; + +/** + * Executes the behavior of the closure. + * + * @param {function()} doneFn Callback function(error, result) + */ +angular.scenario.Future.prototype.execute = function(doneFn) { +  var self = this; +  this.behavior(function(error, result) { +    self.fulfilled = true; +    if (result) { +      try { +        result = self.parser(result); +      } catch(e) { +        error = e; +      } +    } +    self.value = error || result; +    doneFn(error, result); +  }); +}; + +/** + * Configures the future to convert it's final with a function fn(value) + * + * @param {function()} fn function(value) that returns the parsed value + */ +angular.scenario.Future.prototype.parsedWith = function(fn) { +  this.parser = fn; +  return this; +}; + +/** + * Configures the future to parse it's final value from JSON + * into objects. + */ +angular.scenario.Future.prototype.fromJson = function() { +  return this.parsedWith(angular.fromJson); +}; + +/** + * Configures the future to convert it's final value from objects + * into JSON. + */ +angular.scenario.Future.prototype.toJson = function() { +  return this.parsedWith(angular.toJson); +}; diff --git a/src/ngScenario/ObjectModel.js b/src/ngScenario/ObjectModel.js new file mode 100644 index 00000000..b4dad1a5 --- /dev/null +++ b/src/ngScenario/ObjectModel.js @@ -0,0 +1,247 @@ +'use strict'; + +/** + * Maintains an object tree from the runner events. + * + * @param {Object} runner The scenario Runner instance to connect to. + * + * TODO(esprehn): Every output type creates one of these, but we probably + *  want one global shared instance. Need to handle events better too + *  so the HTML output doesn't need to do spec model.getSpec(spec.id) + *  silliness. + * + * TODO(vojta) refactor on, emit methods (from all objects) - use inheritance + */ +angular.scenario.ObjectModel = function(runner) { +  var self = this; + +  this.specMap = {}; +  this.listeners = []; +  this.value = { +    name: '', +    children: {} +  }; + +  runner.on('SpecBegin', function(spec) { +    var block = self.value, +        definitions = []; + +    angular.forEach(self.getDefinitionPath(spec), function(def) { +      if (!block.children[def.name]) { +        block.children[def.name] = { +          id: def.id, +          name: def.name, +          children: {}, +          specs: {} +        }; +      } +      block = block.children[def.name]; +      definitions.push(def.name); +    }); + +    var it = self.specMap[spec.id] = +             block.specs[spec.name] = +             new angular.scenario.ObjectModel.Spec(spec.id, spec.name, definitions); + +    // forward the event +    self.emit('SpecBegin', it); +  }); + +  runner.on('SpecError', function(spec, error) { +    var it = self.getSpec(spec.id); +    it.status = 'error'; +    it.error = error; + +    // forward the event +    self.emit('SpecError', it, error); +  }); + +  runner.on('SpecEnd', function(spec) { +    var it = self.getSpec(spec.id); +    complete(it); + +    // forward the event +    self.emit('SpecEnd', it); +  }); + +  runner.on('StepBegin', function(spec, step) { +    var it = self.getSpec(spec.id); +    var step = new angular.scenario.ObjectModel.Step(step.name); +    it.steps.push(step); + +    // forward the event +    self.emit('StepBegin', it, step); +  }); + +  runner.on('StepEnd', function(spec, step) { +    var it = self.getSpec(spec.id); +    var step = it.getLastStep(); +    if (step.name !== step.name) +      throw 'Events fired in the wrong order. Step names don\'t match.'; +    complete(step); + +    // forward the event +    self.emit('StepEnd', it, step); +  }); + +  runner.on('StepFailure', function(spec, step, error) { +    var it = self.getSpec(spec.id), +        modelStep = it.getLastStep(); + +    modelStep.setErrorStatus('failure', error, step.line()); +    it.setStatusFromStep(modelStep); + +    // forward the event +    self.emit('StepFailure', it, modelStep, error); +  }); + +  runner.on('StepError', function(spec, step, error) { +    var it = self.getSpec(spec.id), +        modelStep = it.getLastStep(); + +    modelStep.setErrorStatus('error', error, step.line()); +    it.setStatusFromStep(modelStep); + +    // forward the event +    self.emit('StepError', it, modelStep, error); +  }); + +  runner.on('RunnerEnd', function() { +    self.emit('RunnerEnd'); +  }); + +  function complete(item) { +    item.endTime = new Date().getTime(); +    item.duration = item.endTime - item.startTime; +    item.status = item.status || 'success'; +  } +}; + +/** + * Adds a listener for an event. + * + * @param {string} eventName Name of the event to add a handler for + * @param {function()} listener Function that will be called when event is fired + */ +angular.scenario.ObjectModel.prototype.on = function(eventName, listener) { +  eventName = eventName.toLowerCase(); +  this.listeners[eventName] = this.listeners[eventName] || []; +  this.listeners[eventName].push(listener); +}; + +/** + * Emits an event which notifies listeners and passes extra + * arguments. + * + * @param {string} eventName Name of the event to fire. + */ +angular.scenario.ObjectModel.prototype.emit = function(eventName) { +  var self = this, +      args = Array.prototype.slice.call(arguments, 1), +      eventName = eventName.toLowerCase(); + +  if (this.listeners[eventName]) { +    angular.forEach(this.listeners[eventName], function(listener) { +      listener.apply(self, args); +    }); +  } +}; + +/** + * Computes the path of definition describe blocks that wrap around + * this spec. + * + * @param spec Spec to compute the path for. + * @return {Array<Describe>} The describe block path + */ +angular.scenario.ObjectModel.prototype.getDefinitionPath = function(spec) { +  var path = []; +  var currentDefinition = spec.definition; +  while (currentDefinition && currentDefinition.name) { +    path.unshift(currentDefinition); +    currentDefinition = currentDefinition.parent; +  } +  return path; +}; + +/** + * Gets a spec by id. + * + * @param {string} The id of the spec to get the object for. + * @return {Object} the Spec instance + */ +angular.scenario.ObjectModel.prototype.getSpec = function(id) { +  return this.specMap[id]; +}; + +/** + * A single it block. + * + * @param {string} id Id of the spec + * @param {string} name Name of the spec + * @param {Array<string>=} definitionNames List of all describe block names that wrap this spec + */ +angular.scenario.ObjectModel.Spec = function(id, name, definitionNames) { +  this.id = id; +  this.name = name; +  this.startTime = new Date().getTime(); +  this.steps = []; +  this.fullDefinitionName = (definitionNames || []).join(' '); +}; + +/** + * Adds a new step to the Spec. + * + * @param {string} step Name of the step (really name of the future) + * @return {Object} the added step + */ +angular.scenario.ObjectModel.Spec.prototype.addStep = function(name) { +  var step = new angular.scenario.ObjectModel.Step(name); +  this.steps.push(step); +  return step; +}; + +/** + * Gets the most recent step. + * + * @return {Object} the step + */ +angular.scenario.ObjectModel.Spec.prototype.getLastStep = function() { +  return this.steps[this.steps.length-1]; +}; + +/** + * Set status of the Spec from given Step + * + * @param {angular.scenario.ObjectModel.Step} step + */ +angular.scenario.ObjectModel.Spec.prototype.setStatusFromStep = function(step) { +  if (!this.status || step.status == 'error') { +    this.status = step.status; +    this.error = step.error; +    this.line = step.line; +  } +}; + +/** + * A single step inside a Spec. + * + * @param {string} step Name of the step + */ +angular.scenario.ObjectModel.Step = function(name) { +  this.name = name; +  this.startTime = new Date().getTime(); +}; + +/** + * Helper method for setting all error status related properties + * + * @param {string} status + * @param {string} error + * @param {string} line + */ +angular.scenario.ObjectModel.Step.prototype.setErrorStatus = function(status, error, line) { +  this.status = status; +  this.error = error; +  this.line = line; +}; diff --git a/src/ngScenario/Runner.js b/src/ngScenario/Runner.js new file mode 100644 index 00000000..06ad3aa1 --- /dev/null +++ b/src/ngScenario/Runner.js @@ -0,0 +1,227 @@ +'use strict'; + +/** + * Runner for scenarios + * + * Has to be initialized before any test is loaded, + * because it publishes the API into window (global space). + */ +angular.scenario.Runner = function($window) { +  this.listeners = []; +  this.$window = $window; +  this.rootDescribe = new angular.scenario.Describe(); +  this.currentDescribe = this.rootDescribe; +  this.api = { +    it: this.it, +    iit: this.iit, +    xit: angular.noop, +    describe: this.describe, +    ddescribe: this.ddescribe, +    xdescribe: angular.noop, +    beforeEach: this.beforeEach, +    afterEach: this.afterEach +  }; +  angular.forEach(this.api, angular.bind(this, function(fn, key) { +    this.$window[key] = angular.bind(this, fn); +  })); +}; + +/** + * Emits an event which notifies listeners and passes extra + * arguments. + * + * @param {string} eventName Name of the event to fire. + */ +angular.scenario.Runner.prototype.emit = function(eventName) { +  var self = this; +  var args = Array.prototype.slice.call(arguments, 1); +  eventName = eventName.toLowerCase(); +  if (!this.listeners[eventName]) +    return; +  angular.forEach(this.listeners[eventName], function(listener) { +    listener.apply(self, args); +  }); +}; + +/** + * Adds a listener for an event. + * + * @param {string} eventName The name of the event to add a handler for + * @param {string} listener The fn(...) that takes the extra arguments from emit() + */ +angular.scenario.Runner.prototype.on = function(eventName, listener) { +  eventName = eventName.toLowerCase(); +  this.listeners[eventName] = this.listeners[eventName] || []; +  this.listeners[eventName].push(listener); +}; + +/** + * Defines a describe block of a spec. + * + * @see Describe.js + * + * @param {string} name Name of the block + * @param {function()} body 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 { +      body.call(this); +    } finally { +      self.currentDescribe = parentDescribe; +    } +  }); +}; + +/** + * Same as describe, but makes ddescribe the only blocks to run. + * + * @see Describe.js + * + * @param {string} name Name of the block + * @param {function()} body Body of the block + */ +angular.scenario.Runner.prototype.ddescribe = function(name, body) { +  var self = this; +  this.currentDescribe.ddescribe(name, function() { +    var parentDescribe = self.currentDescribe; +    self.currentDescribe = this; +    try { +      body.call(this); +    } finally { +      self.currentDescribe = parentDescribe; +    } +  }); +}; + +/** + * Defines a test in a describe block of a spec. + * + * @see Describe.js + * + * @param {string} name Name of the block + * @param {function()} body Body of the block + */ +angular.scenario.Runner.prototype.it = function(name, body) { +  this.currentDescribe.it(name, body); +}; + +/** + * Same as it, but makes iit tests the only tests to run. + * + * @see Describe.js + * + * @param {string} name Name of the block + * @param {function()} body Body of the block + */ +angular.scenario.Runner.prototype.iit = function(name, body) { +  this.currentDescribe.iit(name, body); +}; + +/** + * Defines a function to be called before each it block in the describe + * (and before all nested describes). + * + * @see Describe.js + * + * @param {function()} Callback to execute + */ +angular.scenario.Runner.prototype.beforeEach = function(body) { +  this.currentDescribe.beforeEach(body); +}; + +/** + * Defines a function to be called after each it block in the describe + * (and before all nested describes). + * + * @see Describe.js + * + * @param {function()} Callback to execute + */ +angular.scenario.Runner.prototype.afterEach = function(body) { +  this.currentDescribe.afterEach(body); +}; + +/** + * Creates a new spec runner. + * + * @private + * @param {Object} scope parent scope + */ +angular.scenario.Runner.prototype.createSpecRunner_ = function(scope) { +  var child = scope.$new(); +  var Cls = angular.scenario.SpecRunner; + +  // Export all the methods to child scope manually as now we don't mess controllers with scopes +  // TODO(vojta): refactor scenario runner so that these objects are not tightly coupled as current +  for (var name in Cls.prototype) +    child[name] = angular.bind(child, Cls.prototype[name]); + +  Cls.call(child); +  return child; +}; + +/** + * Runs all the loaded tests with the specified runner class on the + * provided application. + * + * @param {angular.scenario.Application} application App to remote control. + */ +angular.scenario.Runner.prototype.run = function(application) { +  var self = this; +  var $root = angular.injector(['ng']).get('$rootScope'); +  angular.extend($root, this); +  angular.forEach(angular.scenario.Runner.prototype, function(fn, name) { +    $root[name] = angular.bind(self, fn); +  }); +  $root.application = application; +  $root.emit('RunnerBegin'); +  asyncForEach(this.rootDescribe.getSpecs(), function(spec, specDone) { +    var dslCache = {}; +    var runner = self.createSpecRunner_($root); +    angular.forEach(angular.scenario.dsl, function(fn, key) { +      dslCache[key] = fn.call($root); +    }); +    angular.forEach(angular.scenario.dsl, function(fn, key) { +      self.$window[key] = function() { +        var line = callerFile(3); +        var scope = runner.$new(); + +        // Make the dsl accessible on the current chain +        scope.dsl = {}; +        angular.forEach(dslCache, function(fn, key) { +          scope.dsl[key] = function() { +            return dslCache[key].apply(scope, arguments); +          }; +        }); + +        // Make these methods work on the current chain +        scope.addFuture = function() { +          Array.prototype.push.call(arguments, line); +          return angular.scenario.SpecRunner. +            prototype.addFuture.apply(scope, arguments); +        }; +        scope.addFutureAction = function() { +          Array.prototype.push.call(arguments, line); +          return angular.scenario.SpecRunner. +            prototype.addFutureAction.apply(scope, arguments); +        }; + +        return scope.dsl[key].apply(scope, arguments); +      }; +    }); +    runner.run(spec, function() { +      runner.$destroy(); +      specDone.apply(this, arguments); +    }); +  }, +  function(error) { +    if (error) { +      self.emit('RunnerError', error); +    } +    self.emit('RunnerEnd'); +  }); +}; diff --git a/src/ngScenario/Scenario.js b/src/ngScenario/Scenario.js new file mode 100644 index 00000000..cd3c335f --- /dev/null +++ b/src/ngScenario/Scenario.js @@ -0,0 +1,417 @@ +'use strict'; + + +/** + * Setup file for the Scenario. + * Must be first in the compilation/bootstrap list. + */ + +// Public namespace +angular.scenario = angular.scenario || {}; + +/** + * Defines a new output format. + * + * @param {string} name the name of the new output format + * @param {function()} fn function(context, runner) that generates the output + */ +angular.scenario.output = angular.scenario.output || function(name, fn) { +  angular.scenario.output[name] = fn; +}; + +/** + * 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} name The name of the statement + * @param {function()} fn Factory function(), return a function for + *  the statement. + */ +angular.scenario.dsl = 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] = 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} name The name of the matcher + * @param {function()} fn The matching function(expected). + */ +angular.scenario.matcher = angular.scenario.matcher || function(name, fn) { +  angular.scenario.matcher[name] = function(expected) { +    var prefix = 'expect ' + this.future.name + ' '; +    if (this.inverse) { +      prefix += 'not '; +    } +    var self = this; +    this.addFuture(prefix + name + ' ' + angular.toJson(expected), +      function(done) { +        var error; +        self.actual = self.future.value; +        if ((self.inverse && fn.call(self, expected)) || +            (!self.inverse && !fn.call(self, expected))) { +          error = 'expected ' + angular.toJson(expected) + +            ' but was ' + angular.toJson(self.actual); +        } +        done(error); +    }); +  }; +}; + +/** + * Initialize the scenario runner and run ! + * + * Access global window and document object + * Access $runner through closure + * + * @param {Object=} config Config options + */ +angular.scenario.setUpAndRun = function(config) { +  var href = window.location.href; +  var body = _jQuery(document.body); +  var output = []; +  var objModel = new angular.scenario.ObjectModel($runner); + +  if (config && config.scenario_output) { +    output = config.scenario_output.split(','); +  } + +  angular.forEach(angular.scenario.output, function(fn, name) { +    if (!output.length || indexOf(output,name) != -1) { +      var context = body.append('<div></div>').find('div:last'); +      context.attr('id', name); +      fn.call({}, context, $runner, objModel); +    } +  }); + +  if (!/^http/.test(href) && !/^https/.test(href)) { +    body.append('<p id="system-error"></p>'); +    body.find('#system-error').text( +      'Scenario runner must be run using http or https. The protocol ' + +      href.split(':')[0] + ':// is not supported.' +    ); +    return; +  } + +  var appFrame = body.append('<div id="application"></div>').find('#application'); +  var application = new angular.scenario.Application(appFrame); + +  $runner.on('RunnerEnd', function() { +    appFrame.css('display', 'none'); +    appFrame.find('iframe').attr('src', 'about:blank'); +  }); + +  $runner.on('RunnerError', function(error) { +    if (window.console) { +      console.log(formatException(error)); +    } else { +      // Do something for IE +      alert(error); +    } +  }); + +  $runner.run(application); +}; + +/** + * Iterates through list with iterator function that must call the + * continueFunction to continute iterating. + * + * @param {Array} list list to iterate over + * @param {function()} iterator Callback function(value, continueFunction) + * @param {function()} done Callback function(error, result) called when + *   iteration finishes or an error occurs. + */ +function asyncForEach(list, iterator, done) { +  var i = 0; +  function loop(error, index) { +    if (index && index > i) { +      i = index; +    } +    if (error || i >= list.length) { +      done(error); +    } else { +      try { +        iterator(list[i++], loop); +      } catch (e) { +        done(e); +      } +    } +  } +  loop(); +} + +/** + * Formats an exception into a string with the stack trace, but limits + * to a specific line length. + * + * @param {Object} error The exception to format, can be anything throwable + * @param {Number} maxStackLines Optional. max lines of the stack trace to include + *  default is 5. + */ +function formatException(error, maxStackLines) { +  maxStackLines = maxStackLines || 5; +  var message = error.toString(); +  if (error.stack) { +    var stack = error.stack.split('\n'); +    if (stack[0].indexOf(message) === -1) { +      maxStackLines++; +      stack.unshift(error.message); +    } +    message = stack.slice(0, maxStackLines).join('\n'); +  } +  return message; +} + +/** + * Returns a function that gets the file name and line number from a + * location in the stack if available based on the call site. + * + * Note: this returns another function because accessing .stack is very + * expensive in Chrome. + * + * @param {Number} offset Number of stack lines to skip + */ +function callerFile(offset) { +  var error = new Error(); + +  return function() { +    var line = (error.stack || '').split('\n')[offset]; + +    // Clean up the stack trace line +    if (line) { +      if (line.indexOf('@') !== -1) { +        // Firefox +        line = line.substring(line.indexOf('@')+1); +      } else { +        // Chrome +        line = line.substring(line.indexOf('(')+1).replace(')', ''); +      } +    } + +    return line || ''; +  }; +} + +/** + * Triggers a browser event. Attempts to choose the right event if one is + * not specified. + * + * @param {Object} element Either a wrapped jQuery/jqLite node or a DOMElement + * @param {string} type Optional event type. + * @param {Array.<string>=} keys Optional list of pressed keys + *        (valid values: 'alt', 'meta', 'shift', 'ctrl') + */ +function browserTrigger(element, type, keys) { +  if (element && !element.nodeName) element = element[0]; +  if (!element) return; +  if (!type) { +    type = { +        'text':            'change', +        'textarea':        'change', +        'hidden':          'change', +        'password':        'change', +        'button':          'click', +        'submit':          'click', +        'reset':           'click', +        'image':           'click', +        'checkbox':        'click', +        'radio':           'click', +        'select-one':      'change', +        'select-multiple': 'change' +    }[lowercase(element.type)] || 'click'; +  } +  if (lowercase(nodeName_(element)) == 'option') { +    element.parentNode.value = element.value; +    element = element.parentNode; +    type = 'change'; +  } + +  keys = keys || []; +  function pressed(key) { +    return indexOf(keys, key) !== -1; +  } + +  if (msie < 9) { +    switch(element.type) { +      case 'radio': +      case 'checkbox': +        element.checked = !element.checked; +        break; +    } +    // WTF!!! Error: Unspecified error. +    // Don't know why, but some elements when detached seem to be in inconsistent state and +    // calling .fireEvent() on them will result in very unhelpful error (Error: Unspecified error) +    // forcing the browser to compute the element position (by reading its CSS) +    // puts the element in consistent state. +    element.style.posLeft; + +    // TODO(vojta): create event objects with pressed keys to get it working on IE<9 +    var ret = element.fireEvent('on' + type); +    if (lowercase(element.type) == 'submit') { +      while(element) { +        if (lowercase(element.nodeName) == 'form') { +          element.fireEvent('onsubmit'); +          break; +        } +        element = element.parentNode; +      } +    } +    return ret; +  } else { +    var evnt = document.createEvent('MouseEvents'), +        originalPreventDefault = evnt.preventDefault, +        iframe = _jQuery('#application iframe')[0], +        appWindow = iframe ? iframe.contentWindow : window, +        fakeProcessDefault = true, +        finalProcessDefault; + +    // igor: temporary fix for https://bugzilla.mozilla.org/show_bug.cgi?id=684208 +    appWindow.angular['ff-684208-preventDefault'] = false; +    evnt.preventDefault = function() { +      fakeProcessDefault = false; +      return originalPreventDefault.apply(evnt, arguments); +    }; + +    evnt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, pressed('ctrl'), pressed('alt'), +                        pressed('shift'), pressed('meta'), 0, element); + +    element.dispatchEvent(evnt); +    finalProcessDefault = !(appWindow.angular['ff-684208-preventDefault'] || !fakeProcessDefault) + +    delete appWindow.angular['ff-684208-preventDefault']; + +    return finalProcessDefault; +  } +} + +/** + * Don't use the jQuery trigger method since it works incorrectly. + * + * jQuery notifies listeners and then changes the state of a checkbox and + * does not create a real browser event. A real click changes the state of + * the checkbox and then notifies listeners. + * + * To work around this we instead use our own handler that fires a real event. + */ +(function(fn){ +  var parentTrigger = fn.trigger; +  fn.trigger = function(type) { +    if (/(click|change|keydown|blur)/.test(type)) { +      var processDefaults = []; +      this.each(function(index, node) { +        processDefaults.push(browserTrigger(node, type)); +      }); + +      // this is not compatible with jQuery - we return an array of returned values, +      // so that scenario runner know whether JS code has preventDefault() of the event or not... +      return processDefaults; +    } +    return parentTrigger.apply(this, arguments); +  }; +})(_jQuery.fn); + +/** + * Finds all bindings with the substring match of name and returns an + * array of their values. + * + * @param {string} bindExp The name to match + * @return {Array.<string>} String of binding values + */ +_jQuery.fn.bindings = function(windowJquery, bindExp) { +  var result = [], match, +      bindSelector = '.ng-binding:visible'; +  if (angular.isString(bindExp)) { +    bindExp = bindExp.replace(/\s/g, ''); +    match = function (actualExp) { +      if (actualExp) { +        actualExp = actualExp.replace(/\s/g, ''); +        if (actualExp == bindExp) return true; +        if (actualExp.indexOf(bindExp) == 0) { +          return actualExp.charAt(bindExp.length) == '|'; +        } +      } +    } +  } else if (bindExp) { +    match = function(actualExp) { +      return actualExp && bindExp.exec(actualExp); +    } +  } else { +    match = function(actualExp) { +      return !!actualExp; +    }; +  } +  var selection = this.find(bindSelector); +  if (this.is(bindSelector)) { +    selection = selection.add(this); +  } + +  function push(value) { +    if (value == undefined) { +      value = ''; +    } else if (typeof value != 'string') { +      value = angular.toJson(value); +    } +    result.push('' + value); +  } + +  selection.each(function() { +    var element = windowJquery(this), +        binding; +    if (binding = element.data('$binding')) { +      if (typeof binding == 'string') { +        if (match(binding)) { +          push(element.scope().$eval(binding)); +        } +      } else { +        if (!angular.isArray(binding)) { +          binding = [binding]; +        } +        for(var fns, j=0, jj=binding.length;  j<jj; j++) { +          fns = binding[j]; +          if (fns.parts) { +            fns = fns.parts; +          } else { +            fns = [fns]; +          } +          for (var scope, fn, i = 0, ii = fns.length; i < ii; i++) { +            if(match((fn = fns[i]).exp)) { +              push(fn(scope = scope || element.scope())); +            } +          } +        } +      } +    } +  }); +  return result; +}; diff --git a/src/ngScenario/SpecRunner.js b/src/ngScenario/SpecRunner.js new file mode 100644 index 00000000..f4b9b0a7 --- /dev/null +++ b/src/ngScenario/SpecRunner.js @@ -0,0 +1,145 @@ +'use strict'; + +/** + * 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 = []; +  this.afterIndex = 0; +}; + +/** + * Executes a spec which is an it block with associated before/after functions + * based on the describe nesting. + * + * @param {Object} spec A spec object + * @param {function()} specDone function that is called when the spec finshes. Function(error, index) + */ +angular.scenario.SpecRunner.prototype.run = function(spec, specDone) { +  var self = this; +  this.spec = spec; + +  this.emit('SpecBegin', spec); + +  try { +    spec.before.call(this); +    spec.body.call(this); +    this.afterIndex = this.futures.length; +    spec.after.call(this); +  } catch (e) { +    this.emit('SpecError', spec, e); +    this.emit('SpecEnd', spec); +    specDone(); +    return; +  } + +  var handleError = function(error, done) { +    if (self.error) { +      return done(); +    } +    self.error = true; +    done(null, self.afterIndex); +  }; + +  asyncForEach( +    this.futures, +    function(future, futureDone) { +      self.step = future; +      self.emit('StepBegin', spec, future); +      try { +        future.execute(function(error) { +          if (error) { +            self.emit('StepFailure', spec, future, error); +            self.emit('StepEnd', spec, future); +            return handleError(error, futureDone); +          } +          self.emit('StepEnd', spec, future); +          self.$window.setTimeout(function() { futureDone(); }, 0); +        }); +      } catch (e) { +        self.emit('StepError', spec, future, e); +        self.emit('StepEnd', spec, future); +        handleError(e, futureDone); +      } +    }, +    function(e) { +      if (e) { +        self.emit('SpecError', spec, e); +      } +      self.emit('SpecEnd', spec); +      // Call done in a timeout so exceptions don't recursively +      // call this function +      self.$window.setTimeout(function() { specDone(); }, 0); +    } +  ); +}; + +/** + * Adds a new future action. + * + * Note: Do not pass line manually. It happens automatically. + * + * @param {string} name Name of the future + * @param {function()} behavior Behavior of the future + * @param {function()} line fn() that returns file/line number + */ +angular.scenario.SpecRunner.prototype.addFuture = function(name, behavior, line) { +  var future = new angular.scenario.Future(name, angular.bind(this, behavior), line); +  this.futures.push(future); +  return future; +}; + +/** + * Adds a new future action to be executed on the application window. + * + * Note: Do not pass line manually. It happens automatically. + * + * @param {string} name Name of the future + * @param {function()} behavior Behavior of the future + * @param {function()} line fn() that returns file/line number + */ +angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior, line) { +  var self = this; +  var NG = /\[ng\\\:/; +  return this.addFuture(name, function(done) { +    this.application.executeAction(function($window, $document) { + +      //TODO(esprehn): Refactor this so it doesn't need to be in here. +      $document.elements = function(selector) { +        var args = Array.prototype.slice.call(arguments, 1); +        selector = (self.selector || '') + ' ' + (selector || ''); +        selector = _jQuery.trim(selector) || '*'; +        angular.forEach(args, function(value, index) { +          selector = selector.replace('$' + (index + 1), value); +        }); +        var result = $document.find(selector); +        if (selector.match(NG)) { +          result = result.add(selector.replace(NG, '[ng-'), $document); +        } +        if (!result.length) { +          throw { +            type: 'selector', +            message: 'Selector ' + selector + ' did not match any elements.' +          }; +        } + +        return result; +      }; + +      try { +        behavior.call(self, $window, $document, done); +      } catch(e) { +        if (e.type && e.type === 'selector') { +          done(e.message); +        } else { +          throw e; +        } +      } +    }); +  }, line); +}; diff --git a/src/ngScenario/angular-bootstrap.js b/src/ngScenario/angular-bootstrap.js new file mode 100644 index 00000000..a0012ff7 --- /dev/null +++ b/src/ngScenario/angular-bootstrap.js @@ -0,0 +1,60 @@ +'use strict'; + +(function(previousOnLoad){ +  var prefix = (function() { +    var filename = /(.*\/)angular-bootstrap.js(#(.*))?/; +    var scripts = document.getElementsByTagName("script"); +    for(var j = 0; j < scripts.length; j++) { +      var src = scripts[j].src; +      if (src && src.match(filename)) { +        var parts = src.match(filename); +        return parts[1]; +      } +    } +  })(); + +  function addScript(path) { +    document.write('<script type="text/javascript" src="' + prefix + path + '"></script>'); +  } + +  function addCSS(path) { +    document.write('<link rel="stylesheet" type="text/css" href="' + prefix + path + '"/>'); +  } + +  window.onload = function() { +    try { +      if (previousOnLoad) previousOnLoad(); +    } catch(e) {} +    angular.scenario.setUpAndRun({}); +  }; + +  addCSS("../../css/angular-scenario.css"); +  addScript("../../lib/jquery/jquery.js"); +  document.write( +    '<script type="text/javascript">' + +    'var _jQuery = jQuery.noConflict(true);' + +    '</script>' +  ); +  addScript("../angular-bootstrap.js"); + +  addScript("Scenario.js"); +  addScript("Application.js"); +  addScript("Describe.js"); +  addScript("Future.js"); +  addScript("Runner.js"); +  addScript("SpecRunner.js"); +  addScript("dsl.js"); +  addScript("matchers.js"); +  addScript("ObjectModel.js"); +  addScript("output/Html.js"); +  addScript("output/Json.js"); +  addScript("output/Object.js"); +  addScript("output/Xml.js"); + +  // Create the runner (which also sets up the global API) +  document.write( +    '<script type="text/javascript">' + +    '  var $runner = new angular.scenario.Runner(window);' + +    '</script>'); + +})(window.onload); diff --git a/src/ngScenario/angular.prefix b/src/ngScenario/angular.prefix new file mode 100644 index 00000000..439ce371 --- /dev/null +++ b/src/ngScenario/angular.prefix @@ -0,0 +1,7 @@ +/** + * @license AngularJS v"NG_VERSION_FULL" + * (c) 2010-2011 AngularJS http://angularjs.org + * License: MIT + */ +(function(window, document){ +  var _jQuery = window.jQuery.noConflict(true); diff --git a/src/ngScenario/angular.suffix b/src/ngScenario/angular.suffix new file mode 100644 index 00000000..846dbe17 --- /dev/null +++ b/src/ngScenario/angular.suffix @@ -0,0 +1,22 @@ +bindJQuery(); +publishExternalAPI(angular); + +var $runner = new angular.scenario.Runner(window), +    scripts = document.getElementsByTagName('script'), +    script = scripts[scripts.length - 1], +    config = {}; + +angular.forEach(script.attributes, function(attr) { +  var match = attr.name.match(/ng[:\-](.*)/); +  if (match) { +    config[match[1]] = attr.value || true; +  } +}); + +if (config.autotest) { +  JQLite(document).ready(function() { +    angular.scenario.setUpAndRun(config); +  }); +} +})(window, document); + diff --git a/src/ngScenario/dsl.js b/src/ngScenario/dsl.js new file mode 100644 index 00000000..8a1bccb1 --- /dev/null +++ b/src/ngScenario/dsl.js @@ -0,0 +1,405 @@ +'use strict'; + +/** + * Shared DSL statements that are useful to all scenarios. + */ + + /** + * Usage: + *    pause() pauses until you call resume() in the console + */ +angular.scenario.dsl('pause', function() { +  return function() { +    return this.addFuture('pausing for you to resume', function(done) { +      this.emit('InteractivePause', this.spec, this.step); +      this.$window.resume = function() { done(); }; +    }); +  }; +}); + +/** + * Usage: + *    sleep(seconds) pauses the test for specified number of seconds + */ +angular.scenario.dsl('sleep', function() { +  return function(time) { +    return this.addFuture('sleep for ' + time + ' seconds', function(done) { +      this.$window.setTimeout(function() { done(null, time * 1000); }, time * 1000); +    }); +  }; +}); + +/** + * Usage: + *    browser().navigateTo(url) Loads the url into the frame + *    browser().navigateTo(url, fn) where fn(url) is called and returns the URL to navigate to + *    browser().reload() refresh the page (reload the same URL) + *    browser().window.href() window.location.href + *    browser().window.path() window.location.pathname + *    browser().window.search() window.location.search + *    browser().window.hash() window.location.hash without # prefix + *    browser().location().url() see angular.module.ng.$location#url + *    browser().location().path() see angular.module.ng.$location#path + *    browser().location().search() see angular.module.ng.$location#search + *    browser().location().hash() see angular.module.ng.$location#hash + */ +angular.scenario.dsl('browser', function() { +  var chain = {}; + +  chain.navigateTo = function(url, delegate) { +    var application = this.application; +    return this.addFuture("browser navigate to '" + url + "'", function(done) { +      if (delegate) { +        url = delegate.call(this, url); +      } +      application.navigateTo(url, function() { +        done(null, url); +      }, done); +    }); +  }; + +  chain.reload = function() { +    var application = this.application; +    return this.addFutureAction('browser reload', function($window, $document, done) { +      var href = $window.location.href; +      application.navigateTo(href, function() { +        done(null, href); +      }, done); +    }); +  }; + +  chain.window = function() { +    var api = {}; + +    api.href = function() { +      return this.addFutureAction('window.location.href', function($window, $document, done) { +        done(null, $window.location.href); +      }); +    }; + +    api.path = function() { +      return this.addFutureAction('window.location.path', function($window, $document, done) { +        done(null, $window.location.pathname); +      }); +    }; + +    api.search = function() { +      return this.addFutureAction('window.location.search', function($window, $document, done) { +        done(null, $window.location.search); +      }); +    }; + +    api.hash = function() { +      return this.addFutureAction('window.location.hash', function($window, $document, done) { +        done(null, $window.location.hash.replace('#', '')); +      }); +    }; + +    return api; +  }; + +  chain.location = function() { +    var api = {}; + +    api.url = function() { +      return this.addFutureAction('$location.url()', function($window, $document, done) { +        done(null, $window.angular.injector(['ng']).get('$location').url()); +      }); +    }; + +    api.path = function() { +      return this.addFutureAction('$location.path()', function($window, $document, done) { +        done(null, $window.angular.injector(['ng']).get('$location').path()); +      }); +    }; + +    api.search = function() { +      return this.addFutureAction('$location.search()', function($window, $document, done) { +        done(null, $window.angular.injector(['ng']).get('$location').search()); +      }); +    }; + +    api.hash = function() { +      return this.addFutureAction('$location.hash()', function($window, $document, done) { +        done(null, $window.angular.injector(['ng']).get('$location').hash()); +      }); +    }; + +    return api; +  }; + +  return function(time) { +    return chain; +  }; +}); + +/** + * 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; +  }; +}); + +/** + * Usage: + *    using(selector, label) scopes the next DSL element selection + * + * ex. + *   using('#foo', "'Foo' text field").input('bar') + */ +angular.scenario.dsl('using', function() { +  return function(selector, label) { +    this.selector = _jQuery.trim((this.selector||'') + ' ' + selector); +    if (angular.isString(label) && label.length) { +      this.label = label + ' ( ' + this.selector + ' )'; +    } else { +      this.label = this.selector; +    } +    return this.dsl; +  }; +}); + +/** + * Usage: + *    binding(name) returns the value of the first matching binding + */ +angular.scenario.dsl('binding', function() { +  return function(name) { +    return this.addFutureAction("select binding '" + name + "'", function($window, $document, done) { +      var values = $document.elements().bindings($window.angular.element, name); +      if (!values.length) { +        return done("Binding selector '" + name + "' did not match."); +      } +      done(null, values[0]); +    }); +  }; +}); + +/** + * Usage: + *    input(name).enter(value) enters value in input with specified name + *    input(name).check() checks checkbox + *    input(name).select(value) selects the radio button with specified name/value + *    input(name).val() returns the value of the input. + */ +angular.scenario.dsl('input', function() { +  var chain = {}; + +  chain.enter = function(value, event) { +    return this.addFutureAction("input '" + this.name + "' enter '" + value + "'", function($window, $document, done) { +      var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':input'); +      input.val(value); +      input.trigger(event || 'blur'); +      done(); +    }); +  }; + +  chain.check = function() { +    return this.addFutureAction("checkbox '" + this.name + "' toggle", function($window, $document, done) { +      var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':checkbox'); +      input.trigger('click'); +      done(); +    }); +  }; + +  chain.select = function(value) { +    return this.addFutureAction("radio button '" + this.name + "' toggle '" + value + "'", function($window, $document, done) { +      var input = $document. +        elements('[ng\\:model="$1"][value="$2"]', this.name, value).filter(':radio'); +      input.trigger('click'); +      done(); +    }); +  }; + +  chain.val = function() { +    return this.addFutureAction("return input val", function($window, $document, done) { +      var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':input'); +      done(null,input.val()); +    }); +  }; + +  return function(name) { +    this.name = name; +    return chain; +  }; +}); + + +/** + * Usage: + *    repeater('#products table', 'Product List').count() number of rows + *    repeater('#products table', 'Product List').row(1) all bindings in row as an array + *    repeater('#products table', 'Product List').column('product.name') all values across all rows in an array + */ +angular.scenario.dsl('repeater', function() { +  var chain = {}; + +  chain.count = function() { +    return this.addFutureAction("repeater '" + this.label + "' count", function($window, $document, done) { +      try { +        done(null, $document.elements().length); +      } catch (e) { +        done(null, 0); +      } +    }); +  }; + +  chain.column = function(binding) { +    return this.addFutureAction("repeater '" + this.label + "' column '" + binding + "'", function($window, $document, done) { +      done(null, $document.elements().bindings($window.angular.element, binding)); +    }); +  }; + +  chain.row = function(index) { +    return this.addFutureAction("repeater '" + this.label + "' row '" + index + "'", function($window, $document, done) { +      var matches = $document.elements().slice(index, index + 1); +      if (!matches.length) +        return done('row ' + index + ' out of bounds'); +      done(null, matches.bindings($window.angular.element)); +    }); +  }; + +  return function(selector, label) { +    this.dsl.using(selector, label); +    return chain; +  }; +}); + +/** + * Usage: + *    select(name).option('value') select one option + *    select(name).options('value1', 'value2', ...) select options from a multi select + */ +angular.scenario.dsl('select', function() { +  var chain = {}; + +  chain.option = function(value) { +    return this.addFutureAction("select '" + this.name + "' option '" + value + "'", function($window, $document, done) { +      var select = $document.elements('select[ng\\:model="$1"]', this.name); +      var option = select.find('option[value="' + value + '"]'); +      if (option.length) { +        select.val(value); +      } else { +        option = select.find('option:contains("' + value + '")'); +        if (option.length) { +          select.val(option.val()); +        } +      } +      select.trigger('change'); +      done(); +    }); +  }; + +  chain.options = function() { +    var values = arguments; +    return this.addFutureAction("select '" + this.name + "' options '" + values + "'", function($window, $document, done) { +      var select = $document.elements('select[multiple][ng\\:model="$1"]', this.name); +      select.val(values); +      select.trigger('change'); +      done(); +    }); +  }; + +  return function(name) { +    this.name = name; +    return chain; +  }; +}); + +/** + * Usage: + *    element(selector, label).count() get the number of elements that match selector + *    element(selector, label).click() clicks an element + *    element(selector, label).query(fn) executes fn(selectedElements, done) + *    element(selector, label).{method}() gets the value (as defined by jQuery, ex. val) + *    element(selector, label).{method}(value) sets the value (as defined by jQuery, ex. val) + *    element(selector, label).{method}(key) gets the value (as defined by jQuery, ex. attr) + *    element(selector, label).{method}(key, value) sets the value (as defined by jQuery, ex. attr) + */ +angular.scenario.dsl('element', function() { +  var KEY_VALUE_METHODS = ['attr', 'css', 'prop']; +  var VALUE_METHODS = [ +    'val', 'text', 'html', 'height', 'innerHeight', 'outerHeight', 'width', +    'innerWidth', 'outerWidth', 'position', 'scrollLeft', 'scrollTop', 'offset' +  ]; +  var chain = {}; + +  chain.count = function() { +    return this.addFutureAction("element '" + this.label + "' count", function($window, $document, done) { +      try { +        done(null, $document.elements().length); +      } catch (e) { +        done(null, 0); +      } +    }); +  }; + +  chain.click = function() { +    return this.addFutureAction("element '" + this.label + "' click", function($window, $document, done) { +      var elements = $document.elements(); +      var href = elements.attr('href'); +      var eventProcessDefault = elements.trigger('click')[0]; + +      if (href && elements[0].nodeName.toUpperCase() === 'A' && eventProcessDefault) { +        this.application.navigateTo(href, function() { +          done(); +        }, done); +      } else { +        done(); +      } +    }); +  }; + +  chain.query = function(fn) { +    return this.addFutureAction('element ' + this.label + ' custom query', function($window, $document, done) { +      fn.call(this, $document.elements(), done); +    }); +  }; + +  angular.forEach(KEY_VALUE_METHODS, function(methodName) { +    chain[methodName] = function(name, value) { +      var args = arguments, +          futureName = (args.length == 1) +              ? "element '" + this.label + "' get " + methodName + " '" + name + "'" +              : "element '" + this.label + "' set " + methodName + " '" + name + "' to " + "'" + value + "'"; + +      return this.addFutureAction(futureName, function($window, $document, done) { +        var element = $document.elements(); +        done(null, element[methodName].apply(element, args)); +      }); +    }; +  }); + +  angular.forEach(VALUE_METHODS, function(methodName) { +    chain[methodName] = function(value) { +      var args = arguments, +          futureName = (args.length == 0) +              ? "element '" + this.label + "' " + methodName +              : futureName = "element '" + this.label + "' set " + methodName + " to '" + value + "'"; + +      return this.addFutureAction(futureName, function($window, $document, done) { +        var element = $document.elements(); +        done(null, element[methodName].apply(element, args)); +      }); +    }; +  }); + +  return function(selector, label) { +    this.dsl.using(selector, label); +    return chain; +  }; +}); diff --git a/src/ngScenario/jstd-scenario-adapter/Adapter.js b/src/ngScenario/jstd-scenario-adapter/Adapter.js new file mode 100644 index 00000000..9e9cc2ec --- /dev/null +++ b/src/ngScenario/jstd-scenario-adapter/Adapter.js @@ -0,0 +1,177 @@ +'use strict'; + +/** + * JSTestDriver adapter for angular scenario tests + * + * Example of jsTestDriver.conf for running scenario tests with JSTD: +  <pre> +    server: http://localhost:9877 + +    load: +      - lib/angular-scenario.js +      - lib/jstd-scenario-adapter-config.js +      - lib/jstd-scenario-adapter.js +      # your test files go here # + +    proxy: +     - {matcher: "/your-prefix/*", server: "http://localhost:8000/"} +  </pre> + * + * For more information on how to configure jstd proxy, see {@link http://code.google.com/p/js-test-driver/wiki/Proxy} + * Note the order of files - it's important ! + * + * Example of jstd-scenario-adapter-config.js +  <pre> +    var jstdScenarioAdapter = { +      relativeUrlPrefix: '/your-prefix/' +    }; +  </pre> + * + * Whenever you use <code>browser().navigateTo('relativeUrl')</code> in your scenario test, the relativeUrlPrefix will be prepended. + * You have to configure this to work together with JSTD proxy. + * + * Let's assume you are using the above configuration (jsTestDriver.conf and jstd-scenario-adapter-config.js): + * Now, when you call <code>browser().navigateTo('index.html')</code> in your scenario test, the browser will open /your-prefix/index.html. + * That matches the proxy, so JSTD will proxy this request to http://localhost:8000/index.html. + */ + +/** + * Custom type of test case + * + * @const + * @see jstestdriver.TestCaseInfo + */ +var SCENARIO_TYPE = 'scenario'; + +/** + * Plugin for JSTestDriver + * Connection point between scenario's jstd output and jstestdriver. + * + * @see jstestdriver.PluginRegistrar + */ +function JstdPlugin() { +  var nop = function() {}; + +  this.reportResult = nop; +  this.reportEnd = nop; +  this.runScenario = nop; + +  this.name = 'Angular Scenario Adapter'; + +  /** +   * Called for each JSTD TestCase +   * +   * Handles only SCENARIO_TYPE test cases. There should be only one fake TestCase. +   * Runs all scenario tests (under one fake TestCase) and report all results to JSTD. +   * +   * @param {jstestdriver.TestRunConfiguration} configuration +   * @param {Function} onTestDone +   * @param {Function} onAllTestsComplete +   * @returns {boolean} True if this type of test is handled by this plugin, false otherwise +   */ +  this.runTestConfiguration = function(configuration, onTestDone, onAllTestsComplete) { +    if (configuration.getTestCaseInfo().getType() != SCENARIO_TYPE) return false; + +    this.reportResult = onTestDone; +    this.reportEnd = onAllTestsComplete; +    this.runScenario(); + +    return true; +  }; + +  this.getTestRunsConfigurationFor = function(testCaseInfos, expressions, testRunsConfiguration) { +    testRunsConfiguration.push( +        new jstestdriver.TestRunConfiguration( +            new jstestdriver.TestCaseInfo( +                'Angular Scenario Tests', function() {}, SCENARIO_TYPE), [])); + +    return true; +  }; +} + +/** + * Singleton instance of the plugin + * Accessed using closure by: + *  - jstd output (reports to this plugin) + *  - initScenarioAdapter (register the plugin to jstd) + */ +var plugin = new JstdPlugin(); + +/** + * Initialise scenario jstd-adapter + * (only if jstestdriver is defined) + * + * @param {Object} jstestdriver Undefined when run from browser (without jstd) + * @param {Function} initScenarioAndRun Function that inits scenario and runs all the tests + * @param {Object=} config Configuration object, supported properties: + *  - relativeUrlPrefix: prefix for all relative links when navigateTo() + */ +function initScenarioAdapter(jstestdriver, initScenarioAndRun, config) { +  if (jstestdriver) { +    // create and register ScenarioPlugin +    jstestdriver.pluginRegistrar.register(plugin); +    plugin.runScenario = initScenarioAndRun; + +    /** +     * HACK (angular.scenario.Application.navigateTo) +     * +     * We need to navigate to relative urls when running from browser (without JSTD), +     * because we want to allow running scenario tests without creating its own virtual host. +     * For example: http://angular.local/build/docs/docs-scenario.html +     * +     * On the other hand, when running with JSTD, we need to navigate to absolute urls, +     * because of JSTD proxy. (proxy, because of same domain policy) +     * +     * So this hack is applied only if running with JSTD and change all relative urls to absolute. +     */ +    var appProto = angular.scenario.Application.prototype, +        navigateTo = appProto.navigateTo, +        relativeUrlPrefix = config && config.relativeUrlPrefix || '/'; + +    appProto.navigateTo = function(url, loadFn, errorFn) { +      if (url.charAt(0) != '/' && url.charAt(0) != '#' && +          url != 'about:blank' && !url.match(/^https?/)) { +        url = relativeUrlPrefix + url; +      } + +      return navigateTo.call(this, url, loadFn, errorFn); +    }; +  } +} + +/** + * Builds proper TestResult object from given model spec + * + * TODO(vojta) report error details + * + * @param {angular.scenario.ObjectModel.Spec} spec + * @returns {jstestdriver.TestResult} + */ +function createTestResultFromSpec(spec) { +  var map = { +    success: 'PASSED', +    error:   'ERROR', +    failure: 'FAILED' +  }; + +  return new jstestdriver.TestResult( +    spec.fullDefinitionName, +    spec.name, +    jstestdriver.TestResult.RESULT[map[spec.status]], +    spec.error || '', +    spec.line || '', +    spec.duration); +} + +/** + * Generates JSTD output (jstestdriver.TestResult) + */ +angular.scenario.output('jstd', function(context, runner, model) { +  model.on('SpecEnd', function(spec) { +    plugin.reportResult(createTestResultFromSpec(spec)); +  }); + +  model.on('RunnerEnd', function() { +    plugin.reportEnd(); +  }); +}); diff --git a/src/ngScenario/jstd-scenario-adapter/angular.prefix b/src/ngScenario/jstd-scenario-adapter/angular.prefix new file mode 100644 index 00000000..87c14270 --- /dev/null +++ b/src/ngScenario/jstd-scenario-adapter/angular.prefix @@ -0,0 +1,6 @@ +/** + * @license AngularJS v"NG_VERSION_FULL" + * (c) 2010-2011 AngularJS http://angularjs.org + * License: MIT + */ +(function(window) { diff --git a/src/ngScenario/jstd-scenario-adapter/angular.suffix b/src/ngScenario/jstd-scenario-adapter/angular.suffix new file mode 100644 index 00000000..6134fb01 --- /dev/null +++ b/src/ngScenario/jstd-scenario-adapter/angular.suffix @@ -0,0 +1,2 @@ +initScenarioAdapter(window.jstestdriver, angular.scenario.setUpAndRun, window.jstdScenarioAdapter); +})(window); diff --git a/src/ngScenario/matchers.js b/src/ngScenario/matchers.js new file mode 100644 index 00000000..183dce46 --- /dev/null +++ b/src/ngScenario/matchers.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * Matchers for implementing specs. Follows the Jasmine spec conventions. + */ + +angular.scenario.matcher('toEqual', function(expected) { +  return angular.equals(this.actual, expected); +}); + +angular.scenario.matcher('toBe', function(expected) { +  return 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/src/ngScenario/output/Html.js b/src/ngScenario/output/Html.js new file mode 100644 index 00000000..326928d8 --- /dev/null +++ b/src/ngScenario/output/Html.js @@ -0,0 +1,171 @@ +'use strict'; + +/** + * User Interface for the Scenario Runner. + * + * TODO(esprehn): This should be refactored now that ObjectModel exists + *  to use angular bindings for the UI. + */ +angular.scenario.output('html', function(context, runner, model) { +  var specUiMap = {}, +      lastStepUiMap = {}; + +  context.append( +    '<div id="header">' + +    '  <h1><span class="angular">AngularJS</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>' +  ); + +  runner.on('InteractivePause', function(spec, step) { +    var ui = lastStepUiMap[spec.id]; +    ui.find('.test-title'). +      html('paused... <a href="javascript:resume()">resume</a> when ready.'); +  }); + +  runner.on('SpecBegin', function(spec) { +    var ui = findContext(spec); +    ui.find('> .tests').append( +      '<li class="status-pending test-it"></li>' +    ); +    ui = ui.find('> .tests li:last'); +    ui.append( +      '<div class="test-info">' + +      '  <p class="test-title">' + +      '    <span class="timer-result"></span>' + +      '    <span class="test-name"></span>' + +      '  </p>' + +      '</div>' + +      '<div class="scrollpane">' + +      '  <ol class="test-actions"></ol>' + +      '</div>' +    ); +    ui.find('> .test-info .test-name').text(spec.name); +    ui.find('> .test-info').click(function() { +      var scrollpane = ui.find('> .scrollpane'); +      var actions = scrollpane.find('> .test-actions'); +      var name = context.find('> .test-info .test-name'); +      if (actions.find(':visible').length) { +        actions.hide(); +        name.removeClass('open').addClass('closed'); +      } else { +        actions.show(); +        scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); +        name.removeClass('closed').addClass('open'); +      } +    }); + +    specUiMap[spec.id] = ui; +  }); + +  runner.on('SpecError', function(spec, error) { +    var ui = specUiMap[spec.id]; +    ui.append('<pre></pre>'); +    ui.find('> pre').text(formatException(error)); +  }); + +  runner.on('SpecEnd', function(spec) { +    var ui = specUiMap[spec.id]; +    spec = model.getSpec(spec.id); +    ui.removeClass('status-pending'); +    ui.addClass('status-' + spec.status); +    ui.find("> .test-info .timer-result").text(spec.duration + "ms"); +    if (spec.status === 'success') { +      ui.find('> .test-info .test-name').addClass('closed'); +      ui.find('> .scrollpane .test-actions').hide(); +    } +    updateTotals(spec.status); +  }); + +  runner.on('StepBegin', function(spec, step) { +    var ui = specUiMap[spec.id]; +    spec = model.getSpec(spec.id); +    step = spec.getLastStep(); +    ui.find('> .scrollpane .test-actions').append('<li class="status-pending"></li>'); +    var stepUi = lastStepUiMap[spec.id] = ui.find('> .scrollpane .test-actions li:last'); +    stepUi.append( +      '<div class="timer-result"></div>' + +      '<div class="test-title"></div>' +    ); +    stepUi.find('> .test-title').text(step.name); +    var scrollpane = stepUi.parents('.scrollpane'); +    scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); +  }); + +  runner.on('StepFailure', function(spec, step, error) { +    var ui = lastStepUiMap[spec.id]; +    addError(ui, step.line, error); +  }); + +  runner.on('StepError', function(spec, step, error) { +    var ui = lastStepUiMap[spec.id]; +    addError(ui, step.line, error); +  }); + +  runner.on('StepEnd', function(spec, step) { +    var stepUi = lastStepUiMap[spec.id]; +    spec = model.getSpec(spec.id); +    step = spec.getLastStep(); +    stepUi.find('.timer-result').text(step.duration + 'ms'); +    stepUi.removeClass('status-pending'); +    stepUi.addClass('status-' + step.status); +    var scrollpane = specUiMap[spec.id].find('> .scrollpane'); +    scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); +  }); + +  /** +   * Finds the context of a spec block defined by the passed definition. +   * +   * @param {Object} The definition created by the Describe object. +   */ +  function findContext(spec) { +    var currentContext = context.find('#specs'); +    angular.forEach(model.getDefinitionPath(spec), function(defn) { +      var id = 'describe-' + defn.id; +      if (!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>' +        ); +        context.find('#' + id).find('> h2').text('describe: ' + defn.name); +      } +      currentContext = context.find('#' + id); +    }); +    return context.find('#describe-' + spec.definition.id); +  } + +  /** +   * Updates the test counter for the status. +   * +   * @param {string} the status. +   */ +  function updateTotals(status) { +    var legend = context.find('#status-legend .status-' + status); +    var parts = legend.text().split(' '); +    var value = (parts[0] * 1) + 1; +    legend.text(value + ' ' + parts[1]); +  } + +  /** +   * Add an error to a step. +   * +   * @param {Object} The JQuery wrapped context +   * @param {function()} fn() that should return the file/line number of the error +   * @param {Object} the error. +   */ +  function addError(context, line, error) { +    context.find('.test-title').append('<pre></pre>'); +    var message = _jQuery.trim(line() + '\n\n' + formatException(error)); +    context.find('.test-title pre:last').text(message); +  } +}); diff --git a/src/ngScenario/output/Json.js b/src/ngScenario/output/Json.js new file mode 100644 index 00000000..c024d923 --- /dev/null +++ b/src/ngScenario/output/Json.js @@ -0,0 +1,10 @@ +'use strict'; + +/** + * Generates JSON output into a context. + */ +angular.scenario.output('json', function(context, runner, model) { +  model.on('RunnerEnd', function() { +    context.text(angular.toJson(model.value)); +  }); +}); diff --git a/src/ngScenario/output/Object.js b/src/ngScenario/output/Object.js new file mode 100644 index 00000000..621b816f --- /dev/null +++ b/src/ngScenario/output/Object.js @@ -0,0 +1,8 @@ +'use strict'; + +/** + * Creates a global value $result with the result of the runner. + */ +angular.scenario.output('object', function(context, runner, model) { +  runner.$window.$result = model.value; +}); diff --git a/src/ngScenario/output/Xml.js b/src/ngScenario/output/Xml.js new file mode 100644 index 00000000..6cd27fe7 --- /dev/null +++ b/src/ngScenario/output/Xml.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Generates XML output into a context. + */ +angular.scenario.output('xml', function(context, runner, model) { +  var $ = function(args) {return new context.init(args);}; +  model.on('RunnerEnd', function() { +    var scenario = $('<scenario></scenario>'); +    context.append(scenario); +    serializeXml(scenario, model.value); +  }); + +  /** +   * Convert the tree into XML. +   * +   * @param {Object} context jQuery context to add the XML to. +   * @param {Object} tree node to serialize +   */ +  function serializeXml(context, tree) { +     angular.forEach(tree.children, function(child) { +       var describeContext = $('<describe></describe>'); +       describeContext.attr('id', child.id); +       describeContext.attr('name', child.name); +       context.append(describeContext); +       serializeXml(describeContext, child); +     }); +     var its = $('<its></its>'); +     context.append(its); +     angular.forEach(tree.specs, function(spec) { +       var it = $('<it></it>'); +       it.attr('id', spec.id); +       it.attr('name', spec.name); +       it.attr('duration', spec.duration); +       it.attr('status', spec.status); +       its.append(it); +       angular.forEach(spec.steps, function(step) { +         var stepContext = $('<step></step>'); +         stepContext.attr('name', step.name); +         stepContext.attr('duration', step.duration); +         stepContext.attr('status', step.status); +         it.append(stepContext); +         if (step.error) { +           var error = $('<error></error>'); +           stepContext.append(error); +           error.text(formatException(stepContext.error)); +         } +       }); +     }); +   } +});  | 
