From 2115db69035c5993533fe7a3825e64cf6e9068ad Mon Sep 17 00:00:00 2001 From: Elliott Sprehn Date: Tue, 19 Oct 2010 13:17:49 -0700 Subject: Lots of stability and performance updates and UI polish too. Polish the Scenario Runner UI to include: - a scroll pane that steps appear in since the list can be very long - Collapse successful tests - Show the line where the DSL statements were when there's an error (Chrome, Firefox) Also: - Remove lots angular.bind calls to reduce the amount of stack space used. - Use setTimeout(...,0) to schedule the next future to let the browser breathe and have it repaint the steps. Also prevents overflowing the stack when an it() creates many futures. - Run afterEach() handlers even if the it() block fails. - Make navigateTo() take a function as the second argument so you can compute a URL in the future. - Add wait() DSL statement to allow interactive debugging of tests. - Allow custom jQuery selectors with element(...).query(fn) DSL statement. Known Issues: - All afterEach() handlers run even if a beforeEach() handler fails. Only after handlers for the same level as the failure and above should run. --- src/scenario/Describe.js | 10 ++--- src/scenario/Future.js | 18 +++++--- src/scenario/HtmlUI.js | 102 +++++++++++++++++++++++++++++++-------------- src/scenario/Runner.js | 7 +++- src/scenario/Scenario.js | 97 +++++++++++++++++++++++++++++++++++------- src/scenario/SpecRunner.js | 61 +++++++++++++++++++-------- src/scenario/dsl.js | 56 ++++++++++++++++--------- 7 files changed, 255 insertions(+), 96 deletions(-) (limited to 'src') diff --git a/src/scenario/Describe.js b/src/scenario/Describe.js index 896b337f..f6a52f1e 100644 --- a/src/scenario/Describe.js +++ b/src/scenario/Describe.js @@ -78,12 +78,10 @@ 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); - } + name: name, + before: self.setupBefore, + body: body, + after: self.setupAfter }); }; diff --git a/src/scenario/Future.js b/src/scenario/Future.js index 30c2d902..8853aa3f 100644 --- a/src/scenario/Future.js +++ b/src/scenario/Future.js @@ -1,12 +1,17 @@ /** * A future action in a spec. + * + * @param {String} name of the future action + * @param {Function} future callback(error, result) + * @param {String} Optional. function that returns the file/line number. */ -angular.scenario.Future = function(name, behavior) { +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 ''; }; }; /** @@ -15,18 +20,19 @@ angular.scenario.Future = function(name, behavior) { * @param {Function} Callback function(error, result) */ angular.scenario.Future.prototype.execute = function(doneFn) { - this.behavior(angular.bind(this, function(error, result) { - this.fulfilled = true; + var self = this; + this.behavior(function(error, result) { + self.fulfilled = true; if (result) { try { - result = this.parser(result); + result = self.parser(result); } catch(e) { error = e; } } - this.value = error || result; + self.value = error || result; doneFn(error, result); - })); + }); }; /** diff --git a/src/scenario/HtmlUI.js b/src/scenario/HtmlUI.js index a93ed1e3..78fe8c33 100644 --- a/src/scenario/HtmlUI.js +++ b/src/scenario/HtmlUI.js @@ -20,24 +20,30 @@ angular.scenario.ui.Html = function(context) { ); }; +/** + * The severity order of an error. + */ +angular.scenario.ui.Html.SEVERITY = ['pending', 'success', 'failure', 'error']; + /** * 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 self = this; 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) { - status = this.context.find('#status-legend .status-' + status); + return new angular.scenario.ui.Html.Spec(specContext, spec.name, + function(status) { + status = self.context.find('#status-legend .status-' + status); var parts = status.text().split(' '); var value = (parts[0] * 1) + 1; status.text(value + ' ' + parts[1]); - }) + } ); }; @@ -47,6 +53,7 @@ angular.scenario.ui.Html.prototype.addSpec = function(spec) { * @param {Object} The definition created by the Describe object. */ angular.scenario.ui.Html.prototype.findContext = function(definition) { + var self = this; var path = []; var currentContext = this.context.find('#specs'); var currentDefinition = definition; @@ -54,9 +61,9 @@ angular.scenario.ui.Html.prototype.findContext = function(definition) { path.unshift(currentDefinition); currentDefinition = currentDefinition.parent; } - angular.foreach(path, angular.bind(this, function(defn) { + angular.foreach(path, function(defn) { var id = 'describe-' + defn.id; - if (!this.context.find('#' + id).length) { + if (!self.context.find('#' + id).length) { currentContext.find('> .test-children').append( '
    ' + '

    ' + @@ -64,10 +71,10 @@ angular.scenario.ui.Html.prototype.findContext = function(definition) { ' ' + '
    ' ); - this.context.find('#' + id).find('> h2').text('describe: ' + defn.name); + self.context.find('#' + id).find('> h2').text('describe: ' + defn.name); } - currentContext = this.context.find('#' + id); - })); + currentContext = self.context.find('#' + id); + }); return this.context.find('#describe-' + definition.id); }; @@ -90,9 +97,24 @@ angular.scenario.ui.Html.Spec = function(context, name, doneFn) { ' ' + '

    ' + '' + - '
      ' + - '
    ' + '
    ' + + '
      ' + + '
    ' + + '
    ' ); + context.find('> .test-info').click(function() { + var scrollpane = context.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'); + } + }); context.find('> .test-info .test-name').text('it ' + name); }; @@ -100,13 +122,20 @@ angular.scenario.ui.Html.Spec = function(context, name, doneFn) { * Adds a new Step to this spec and returns it. * * @param {String} The name of the step. + * @param {Function} function() that returns a string with the file/line number + * where the step was added from. */ -angular.scenario.ui.Html.Spec.prototype.addStep = function(name) { - this.context.find('> .test-actions').append('
  • '); - var stepContext = this.context.find('> .test-actions li:last'); +angular.scenario.ui.Html.Spec.prototype.addStep = function(name, location) { + this.context.find('> .scrollpane .test-actions').append('
  • '); + var stepContext = this.context.find('> .scrollpane .test-actions li:last'); var self = this; - return new angular.scenario.ui.Html.Step(stepContext, name, function(status) { - self.status = status; + return new angular.scenario.ui.Html.Step(stepContext, name, location, function(status) { + if (indexOf(angular.scenario.ui.Html.SEVERITY, status) > + indexOf(angular.scenario.ui.Html.SEVERITY, self.status)) { + self.status = status; + } + var scrollpane = self.context.find('> .scrollpane'); + scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); }); }; @@ -118,6 +147,10 @@ angular.scenario.ui.Html.Spec.prototype.complete = function() { var endTime = new Date().getTime(); this.context.find("> .test-info .timer-result"). text((endTime - this.startTime) + "ms"); + if (this.status === 'success') { + this.context.find('> .test-info .test-name').addClass('closed'); + this.context.find('> .scrollpane .test-actions').hide(); + } }; /** @@ -125,15 +158,8 @@ angular.scenario.ui.Html.Spec.prototype.complete = function() { * * @param {Object} An optional error */ -angular.scenario.ui.Html.Spec.prototype.finish = function(error) { +angular.scenario.ui.Html.Spec.prototype.finish = function() { 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);
     };
    @@ -144,7 +170,10 @@ angular.scenario.ui.Html.Spec.prototype.finish = function(error) {
      * @param {Object} Required error
      */
     angular.scenario.ui.Html.Spec.prototype.error = function(error) {
    -  this.finish(error);
    +  this.status = 'error';
    +  this.context.append('
    ');
    +  this.context.find('> pre').text(formatException(error));
    +  this.finish();
     };
     
     /**
    @@ -152,28 +181,39 @@ angular.scenario.ui.Html.Spec.prototype.error = function(error) {
      *
      * @param {Object} The jQuery object for the context of the step.
      * @param {String} The name of the step.
    + * @param {Function} function() that returns file/line number of step.
      * @param {Function} Callback function(status) to call when complete.
      */
    -angular.scenario.ui.Html.Step = function(context, name, doneFn) {
    +angular.scenario.ui.Html.Step = function(context, name, location, doneFn) {
       this.context = context;
       this.name = name;
    +  this.location = location;
       this.startTime = new Date().getTime();
       this.doneFn = doneFn;
       context.append(
    -    '' +
    -    ''
    +    '
    ' + + '
    ' ); context.find('> .test-title').text(name); + var scrollpane = context.parents('.scrollpane'); + scrollpane.attr('scrollTop', scrollpane.attr('scrollHeight')); }; /** * Completes the step and sets the timer value. */ -angular.scenario.ui.Html.Step.prototype.complete = function() { +angular.scenario.ui.Html.Step.prototype.complete = function(error) { this.context.removeClass('status-pending'); var endTime = new Date().getTime(); this.context.find(".timer-result"). text((endTime - this.startTime) + "ms"); + if (error) { + if (!this.context.find('.test-title pre').length) { + this.context.find('.test-title').append('
    ');
    +    }
    +    var message = _jQuery.trim(this.location() + '\n\n' + formatException(error));
    +    this.context.find('.test-title pre').text(message);
    +  }
     };
     
     /**
    @@ -182,7 +222,7 @@ angular.scenario.ui.Html.Step.prototype.complete = function() {
      * @param {Object} An optional error
      */
     angular.scenario.ui.Html.Step.prototype.finish = function(error) {
    -  this.complete();
    +  this.complete(error);
       if (error) {
         this.context.addClass('status-failure');
         this.doneFn('failure');
    @@ -198,7 +238,7 @@ angular.scenario.ui.Html.Step.prototype.finish = function(error) {
      * @param {Object} Required error
      */
     angular.scenario.ui.Html.Step.prototype.error = function(error) {
    -  this.complete();
    +  this.complete(error);
       this.context.addClass('status-error');
       this.doneFn('error');
     };
    diff --git a/src/scenario/Runner.js b/src/scenario/Runner.js
    index 55360592..a8b23f83 100644
    --- a/src/scenario/Runner.js
    +++ b/src/scenario/Runner.js
    @@ -91,6 +91,7 @@ angular.scenario.Runner.prototype.run = function(ui, application, specRunnerClas
         });
         angular.foreach(angular.scenario.dsl, function(fn, key) {
           self.$window[key] = function() {
    +        var line = callerFile(3);
             var scope = angular.scope(runner);
     
             // Make the dsl accessible on the current chain
    @@ -103,10 +104,12 @@ angular.scenario.Runner.prototype.run = function(ui, application, specRunnerClas
     
             // Make these methods work on the current chain
             scope.addFuture = function() {
    -          return angular.scenario.SpecRunner.prototype.addFuture.apply(scope, arguments);
    +          Array.prototype.push.call(arguments, line);
    +          return specRunnerClass.prototype.addFuture.apply(scope, arguments);
             };
             scope.addFutureAction = function() {
    -          return angular.scenario.SpecRunner.prototype.addFutureAction.apply(scope, arguments);
    +          Array.prototype.push.call(arguments, line);
    +          return specRunnerClass.prototype.addFutureAction.apply(scope, arguments);
             };
     
             return scope.dsl[key].apply(scope, arguments);
    diff --git a/src/scenario/Scenario.js b/src/scenario/Scenario.js
    index 979210e5..b1782cf2 100644
    --- a/src/scenario/Scenario.js
    +++ b/src/scenario/Scenario.js
    @@ -32,9 +32,9 @@ angular.scenario.dsl = angular.scenario.dsl || function(name, fn) {
           var chain = angular.extend({}, result);
           angular.foreach(chain, function(value, name) {
             if (angular.isFunction(value)) {
    -          chain[name] = angular.bind(self, function() {
    +          chain[name] = function() {
                 return executeStatement.call(self, value, arguments);
    -          });
    +          };
             } else {
               chain[name] = value;
             }
    @@ -63,17 +63,18 @@ angular.scenario.matcher = angular.scenario.matcher || function(name, fn) {
         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);
    +    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(this.error);
    -      })
    -    );
    +        done(error);
    +    });
       };
     };
     
    @@ -88,7 +89,10 @@ angular.scenario.matcher = angular.scenario.matcher || function(name, fn) {
      */
     function asyncForEach(list, iterator, done) {
       var i = 0;
    -  function loop(error) {
    +  function loop(error, index) {
    +    if (index && index > i) {
    +      i = index;
    +    }
         if (error || i >= list.length) {
           done(error);
         } else {
    @@ -102,7 +106,63 @@ function asyncForEach(list, iterator, done) {
       loop();
     }
     
    +/**
    + * Formats an exception into a string with the stack trace, but limits
    + * to a specific line length.
    + *
    + * @param {Object} the exception to format, can be anything throwable
    + * @param {Number} 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.
    + */
    +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} Either a wrapped jQuery/jqLite node or a DOMElement
    + * @param {String} Optional event type.
    + */
     function browserTrigger(element, type) {
       if (element && !element.nodeName) element = element[0];
       if (!element) return;
    @@ -136,10 +196,17 @@ function browserTrigger(element, type) {
       }
     }
     
    +/**
    + * 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.
    + */
     _jQuery.fn.trigger = function(type) {
       return this.each(function(index, node) {
         browserTrigger(node, type);
       });
     };
    -
    -
    diff --git a/src/scenario/SpecRunner.js b/src/scenario/SpecRunner.js
    index 26fa9b91..98ce4b53 100644
    --- a/src/scenario/SpecRunner.js
    +++ b/src/scenario/SpecRunner.js
    @@ -8,6 +8,7 @@
      */
     angular.scenario.SpecRunner = function() {
       this.futures = [];
    +  this.afterIndex = 0;
     };
     
     /**
    @@ -20,32 +21,52 @@ angular.scenario.SpecRunner = function() {
      * @param {Function} Callback function that is called when the  spec finshes.
      */
     angular.scenario.SpecRunner.prototype.run = function(ui, spec, specDone) {
    +  var self = this;
       var specUI = ui.addSpec(spec);
     
       try {
    -    spec.fn.call(this);
    +    spec.before.call(this);
    +    spec.body.call(this);
    +    this.afterIndex = this.futures.length;
    +    spec.after.call(this);
       } catch (e) {
         specUI.error(e);
         specDone();
         return;
       }
     
    +  var handleError = function(error, done) {
    +    if (self.error) {
    +      return done();
    +    }
    +    self.error = true;
    +    done(null, self.afterIndex);
    +  };
    +  
    +  var spec = this;
       asyncForEach(
         this.futures,
         function(future, futureDone) {
    -      var stepUI = specUI.addStep(future.name);
    +      var stepUI = specUI.addStep(future.name, future.line);
           try {
             future.execute(function(error) {
               stepUI.finish(error);
    -          futureDone(error);
    +          if (error) {
    +            return handleError(error, futureDone);
    +          }
    +          spec.$window.setTimeout( function() { futureDone(); }, 0);
             });
           } catch (e) {
             stepUI.error(e);
    -        throw e;
    +        handleError(e, futureDone);
           }
         },
         function(e) {
    -      specUI.finish(e);
    +      if (e) {
    +        specUI.error(e);
    +      } else {
    +        specUI.finish();
    +      }
           specDone();
         }
       );
    @@ -54,11 +75,14 @@ angular.scenario.SpecRunner.prototype.run = function(ui, spec, specDone) {
     /**
      * Adds a new future action.
      *
    + * Note: Do not pass line manually. It happens automatically.
    + *
      * @param {String} Name of the future
      * @param {Function} Behavior of the future
    + * @param {Function} fn() that returns file/line number
      */
    -angular.scenario.SpecRunner.prototype.addFuture = function(name, behavior) {
    -  var future = new angular.scenario.Future(name, angular.bind(this, behavior));
    +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;
     };
    @@ -66,17 +90,20 @@ angular.scenario.SpecRunner.prototype.addFuture = function(name, behavior) {
     /**
      * Adds a new future action to be executed on the application window.
      *
    + * Note: Do not pass line manually. It happens automatically.
    + *
      * @param {String} Name of the future
      * @param {Function} Behavior of the future
    + * @param {Function} fn() that returns file/line number 
      */
    -angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior) {
    +angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior, line) {
    +  var self = this;
       return this.addFuture(name, function(done) {
    -    this.application.executeAction(angular.bind(this, function($window, $document) {
    -
    -      $document.elements = angular.bind(this, function(selector) {
    +    this.application.executeAction(function($window, $document) {
    +      $document.elements = function(selector) {
             var args = Array.prototype.slice.call(arguments, 1);
    -        if (this.selector) {
    -          selector = this.selector + ' ' + (selector || '');
    +        if (self.selector) {
    +          selector = self.selector + ' ' + (selector || '');
             }
             angular.foreach(args, function(value, index) {
               selector = selector.replace('$' + (index + 1), value);
    @@ -90,10 +117,10 @@ angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior)
             }
     
             return result;
    -      });
    +      };
     
           try {
    -        behavior.call(this, $window, $document, done);
    +        behavior.call(self, $window, $document, done);
           } catch(e) {
             if (e.type && e.type === 'selector') {
               done(e.message);
    @@ -101,6 +128,6 @@ angular.scenario.SpecRunner.prototype.addFutureAction = function(name, behavior)
               throw e;
             }
           }
    -    }));
    -  });
    +    });
    +  }, line);
     };
    diff --git a/src/scenario/dsl.js b/src/scenario/dsl.js
    index 69af39db..f4484df8 100644
    --- a/src/scenario/dsl.js
    +++ b/src/scenario/dsl.js
    @@ -1,6 +1,18 @@
     /**
      * Shared DSL statements that are useful to all scenarios.
      */
    + 
    + /**
    + * Usage:
    + *    wait() waits until you call resume() in the console
    + */
    + angular.scenario.dsl('wait', function() {
    +  return function() {
    +    return this.addFuture('waiting for you to call resume() in the console', function(done) {
    +      this.$window.resume = function() { done(); };
    +    });
    +  };
    + });
     
     /**
     * Usage:
    @@ -41,23 +53,22 @@ angular.scenario.dsl('expect', function() {
      *    of a  URL to navigate to
      */
     angular.scenario.dsl('navigateTo', function() {
    -  return function(url) {
    +  return function(url, delegate) {
         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() {
    +    return this.addFuture('navigate to ' + url, function(done) {
    +      if (delegate) {
    +        url = delegate.call(this, url);
    +      }
    +      application.navigateTo(url, function() {
             application.executeAction(function($window) {
               if ($window.angular) {
                 var $browser = $window.angular.service.$browser();
                 $browser.poll();
                 $browser.notifyWhenNoOutstandingRequests(function() {
    -              done(null, url.value || url);
    +              done(null, url);
                 });
               } else {
    -            done(null, url.value || url);
    +            done(null, url);
               }
             });
           });
    @@ -142,9 +153,9 @@ angular.scenario.dsl('input', function() {
     
     /**
      * Usage:
    - *    repeater('#products table').count() // number of rows
    - *    repeater('#products table').row(1) // all bindings in row as an array
    - *    repeater('#products table').column('product.name') // all values across all rows in an array
    + *    repeater('#products table').count() number of rows
    + *    repeater('#products table').row(1) all bindings in row as an array
    + *    repeater('#products table').column('product.name') all values across all rows in an array
      */
     angular.scenario.dsl('repeater', function() {
       var chain = {};
    @@ -194,8 +205,8 @@ angular.scenario.dsl('repeater', function() {
     
     /**
      * Usage:
    - *    select(selector).option('value') // select one option
    - *    select(selector).options('value1', 'value2', ...) // select options from a multi select
    + *    select(selector).option('value') select one option
    + *    select(selector).options('value1', 'value2', ...) select options from a multi select
      */
     angular.scenario.dsl('select', function() {
       var chain = {};
    @@ -227,11 +238,12 @@ angular.scenario.dsl('select', function() {
     
     /**
      * Usage:
    - *    element(selector).click() // clicks an element
    - *    element(selector).attr(name) // gets the value of an attribute
    - *    element(selector).attr(name, value) // sets the value of an attribute
    - *    element(selector).val() // gets the value (as defined by jQuery)
    - *    element(selector).val(value) // sets the value (as defined by jQuery)
    + *    element(selector).click() clicks an element
    + *    element(selector).attr(name) gets the value of an attribute
    + *    element(selector).attr(name, value) sets the value of an attribute
    + *    element(selector).val() gets the value (as defined by jQuery)
    + *    element(selector).val(value) sets the value (as defined by jQuery)
    + *    element(selector).query(fn) executes fn(selectedElements, done)
      */
     angular.scenario.dsl('element', function() {
       var chain = {};
    @@ -263,6 +275,12 @@ angular.scenario.dsl('element', function() {
         });
       };
     
    +  chain.query = function(fn) {
    +    return this.addFutureAction('element ' + this.selector + ' custom query', function($window, $document, done) {
    +      fn.call(this, $document.elements(), done);
    +    });
    +  };
    +
       return function(selector) {
         this.dsl.using(selector);
         return chain;
    -- 
    cgit v1.2.3