aboutsummaryrefslogtreecommitdiffstats
path: root/src/ngScenario/Scenario.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/ngScenario/Scenario.js')
-rw-r--r--src/ngScenario/Scenario.js417
1 files changed, 417 insertions, 0 deletions
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;
+};