lib/bauxite/core/context.rb in bauxite-0.6.18 vs lib/bauxite/core/context.rb in bauxite-0.6.19

- old
+ new

@@ -1,791 +1,791 @@ -#-- -# Copyright (c) 2014 Patricio Zavolinsky -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -#++ - -require 'selenium-webdriver' -require 'readline' -require 'csv' -require 'pathname' - -# Load dependencies and extensions without leaking dir into the global scope -lambda do - dir = File.expand_path(File.dirname(__FILE__)) - Dir[File.join(dir, '*.rb')].each { |file| require file } - Dir[File.join(dir, '..', 'actions' , '*.rb')].each { |file| require file } - Dir[File.join(dir, '..', 'selectors', '*.rb')].each { |file| require file } - Dir[File.join(dir, '..', 'loggers' , '*.rb')].each { |file| require file } - Dir[File.join(dir, '..', 'parsers' , '*.rb')].each { |file| require file } -end.call - -# Bauxite Namespace -module Bauxite - # The Main test context. This class includes state and helper functions - # used by clients execute tests and by actions and selectors to interact - # with the test engine (i.e. Selenium WebDriver). - # - # === Context variables - # Context variables are a key/value pairs scoped to the a test context. - # - # Variables can be set using different actions. For example: - # - Action#set sets a variable to a literal string. - # - Action#store sets a variable to the value of an element in the page. - # - Action#exec sets a variable to the output of an external command - # (i.e. stdout). - # - Action#js sets a variable to the result of Javascript command. - # - Action#replace sets a variable to the result of doing a - # find-and-replace operation on a literal. - # - # Variables can be expanded in every Action argument (e.g. selectors, - # texts, expressions, etc.). To obtain the value of a variable through - # variable expansion the following syntax must be used: - # ${var_name} - # - # For example: - # set field "greeting" - # set name "John" - # write "${field}_textbox" "Hi, my name is ${name}!" - # click "${field}_button" - # - # === Variable scope - # When the main test starts (via the #start method), the test is bound - # to the global scope. The variables defined in the global scope are - # available to every test Action. - # - # The global scope can have nested variable scopes created by special - # actions. The variables defined in a scope +A+ are only available to that - # scope and scopes nested within +A+. - # - # Every time an Action loads a file, a new nested scope is created. - # File-loading actions include: - # - Action#load - # - Action#tryload - # - Action#ruby - # - Action#test - # - # A nested scope can bubble variables to its parent scope with the special - # action: - # - Action#return_action - # - # === Built-in variable - # Bauxite has a series of built-in variables that provide information of - # the current test context and allow dynamic constomizations of the test - # behavior. - # - # The built-in variables are: - # [<tt>__FILE__</tt>] The file where the current action is defined. - # [<tt>__DIR__</tt>] The directory where <tt>__FILE__</tt> is. - # [<tt>__SELECTOR__</tt>] The default selector used when the selector - # specified does not contain an <tt>=</tt> - # character. - # [<tt>__DEBUG__</tt>] Set to true if the current action is being executed - # by the debug console. - # [<tt>__RETURN__</tt>] Used internally by Action#return_action to indicate - # which variables should be returned to the parent - # scope. - # - # In general, variables surrounded by double underscores and variables - # whose names are only numbers are reserved for Bauxite and should not be - # used as part of a functional test. The obvious exception is when trying - # to change the test behavior by changing the built-in variables. - # - class Context - # Logger instance. - attr_reader :logger - - # Test options. - attr_reader :options - - # Context variables. - attr_accessor :variables - - # Test containers. - attr_accessor :tests - - # Constructs a new test context instance. - # - # +options+ is a hash with the following values: - # [:driver] selenium driver symbol (defaults to +:firefox+) - # [:timeout] selector timeout in seconds (defaults to +10s+) - # [:logger] logger implementation name without the 'Logger' suffix - # (defaults to 'null' for Loggers::NullLogger). - # [:verbose] if +true+, show verbose error information (e.g. - # backtraces) if an error occurs (defaults to +false+) - # [:debug] if +true+, break into the #debug console if an error occurs - # (defaults to +false+) - # [:wait] if +true+, call ::wait before stopping the test engine with - # #stop (defaults to +false+) - # [:extensions] an array of directories that contain extensions to be - # loaded - # - def initialize(options) - @options = options - @driver_name = (options[:driver] || :firefox).to_sym - @variables = { - '__TIMEOUT__' => (options[:timeout] || 10).to_i, - '__DEBUG__' => false, - '__SELECTOR__' => options[:selector] || 'sid', - '__OUTPUT__' => options[:output], - '__DIR__' => File.absolute_path(Dir.pwd) - } - @aliases = {} - @tests = [] - - client = Selenium::WebDriver::Remote::Http::Default.new - client.timeout = (@options[:open_timeout] || 60).to_i - @options[:driver_opt] = {} unless @options[:driver_opt] - @options[:driver_opt][:http_client] = client - - _load_extensions(options[:extensions] || []) - - @logger = Context::load_logger(options[:logger], options[:logger_opt]) - - @parser = Parser.new(self) - end - - # Starts the test engine and executes the actions specified. If no action - # was specified, returns without stopping the test engine (see #stop). - # - # For example: - # lines = [ - # 'open "http://www.ruby-lang.org"', - # 'write "name=q" "ljust"', - # 'click "name=sa"', - # 'break' - # ] - # ctx.start(lines) - # # => navigates to www.ruby-lang.org, types ljust in the search box - # # and clicks the "Search" button. - # - def start(actions = []) - return unless actions.size > 0 - begin - actions.each do |action| - begin - break if exec_action(action) == :break - rescue StandardError => e - print_error(e) - raise unless @options[:debug] - debug - end - end - ensure - stop - end - end - - # Stops the test engine and starts a new engine with the same provider. - # - # For example: - # ctx.reset_driver - # => closes the browser and opens a new one - # - def reset_driver - @driver.quit if @driver - @driver = nil - end - - # Stops the test engine. - # - # Calling this method at the end of the test is mandatory if #start was - # called without +actions+. - # - # Note that the recommeneded way of executing tests is by passing a list - # of +actions+ to #start instead of using the #start / #stop pattern. - # - # For example: - # ctx.start(:firefox) # => opens firefox - # - # # test stuff goes here - # - # ctx.stop # => closes firefox - # - def stop - Context::wait if @options[:wait] - begin - @logger.finalize(self) - rescue StandardError => e - print_error(e) - raise - ensure - @driver.quit if @driver - end - end - - # Finds an element by +selector+. - # - # The element found is yielded to the given +block+ (if any) and returned. - # - # Note that the recommeneded way to call this method is by passing a - # +block+. This is because the method ensures that the element context is - # maintained for the duration of the +block+ but it makes no guarantees - # after the +block+ completes (the same applies if no +block+ was given). - # - # For example: - # ctx.find('css=.my_button') { |element| element.click } - # ctx.find('css=.my_button').click - # - # For example (where using a +block+ is mandatory): - # ctx.find('frame=|myframe|css=.my_button') { |element| element.click } - # # => .my_button clicked - # - # ctx.find('frame=|myframe|css=.my_button').click - # # => error, cannot click .my_button (no longer in myframe scope) - # - def find(selector, &block) # yields: element - with_timeout Selenium::WebDriver::Error::NoSuchElementError do - Selector.new(self, @variables['__SELECTOR__']).find(selector, &block) - end - end - - # Test engine driver instance (Selenium WebDriver). - def driver - _load_driver unless @driver - @driver - end - - # Breaks into the debug console. - # - # For example: - # ctx.debug - # # => this breaks into the debug console - def debug - exec_parsed_action('debug', [], false) - end - - # Returns the value of the specified +element+. - # - # This method takes into account the type of element and selectively - # returns the inner text or the value of the +value+ attribute. - # - # For example: - # # assuming <input type='text' value='Hello' /> - # # <span id='label'>World!</span> - # - # ctx.get_value(ctx.find('css=input[type=text]')) - # # => returns 'Hello' - # - # ctx.get_value(ctx.find('label')) - # # => returns 'World!' - # - def get_value(element) - if ['input','select','textarea'].include? element.tag_name.downcase - element.attribute('value') - else - element.text - end - end - - # ======================================================================= # - # :section: Advanced Helpers - # ======================================================================= # - - # Executes the specified action string handling errors, logging and debug - # history. - # - # If +log+ is +true+, log the action execution (default behavior). - # - # For example: - # ctx.exec_action 'open "http://www.ruby-lang.org"' - # # => navigates to www.ruby-lang.org - # - def exec_action(text) - data = Context::parse_action_default(text, '<unknown>', 0) - exec_parsed_action(data[:action], data[:args], true, text) - end - - # Executes the specified +file+. - # - # For example: - # ctx.exec_file('file') - # # => executes every action defined in 'file' - # - def exec_file(file) - current_dir = @variables['__DIR__' ] - current_file = @variables['__FILE__'] - current_line = @variables['__LINE__'] - - @parser.parse(file) do |action, args, text, file, line| - @variables['__DIR__'] = File.absolute_path(File.dirname(file)) - @variables['__FILE__'] = file - @variables['__LINE__'] = line - break if exec_parsed_action(action, args, true, text) == :break - end - - @variables['__DIR__' ] = current_dir - @variables['__FILE__'] = current_file - @variables['__LINE__'] = current_line - end - - # Executes the specified action handling errors, logging and debug - # history. - # - # If +log+ is +true+, log the action execution (default behavior). - # - # This method if part of the action execution chain and is intended - # for advanced use (e.g. in complex actions). To execute an Action - # directly, the #exec_action method is preferred. - # - # For example: - # ctx.exec_action 'open "http://www.ruby-lang.org"' - # # => navigates to www.ruby-lang.org - # - def exec_parsed_action(action, args, log = true, text = nil) - action = get_action(action, args, text) - ret = nil - if log - @logger.log_cmd(action) do - Readline::HISTORY << action.text - ret = exec_action_object(action) - end - else - ret = exec_action_object(action) - end - - if ret.respond_to? :call # delayed actions (after log_cmd) - ret.call - else - ret - end - rescue Selenium::WebDriver::Error::UnhandledAlertError - raise Bauxite::Errors::AssertionError, "Unexpected modal present" - end - - # Executes the given block retrying for at most <tt>${__TIMEOUT__}</tt> - # seconds. Note that this method does not take into account the time it - # takes to execute the block itself. - # - # For example - # ctx.with_timeout StandardError do - # ctx.find ('element_with_delay') do |e| - # # do something with e - # end - # end - # - def with_timeout(*error_types) - stime = Time.new - timeout ||= stime + @variables['__TIMEOUT__'] - yield - rescue *error_types => e - t = Time.new - rem = timeout - t - raise if rem < 0 - - @logger.progress(rem.round) - - sleep(1.0/10.0) if (t - stime).to_i < 1 - retry - end - - # Executes the given block using the specified driver +timeout+. - # - # Note that the driver +timeout+ is the time (in seconds) Selenium - # will wait for a specific element to appear in the page (using any - # of the available Selector strategies). - # - # For example - # ctx.with_driver_timeout 0.5 do - # ctx.find ('find_me_quickly') do |e| - # # do something with e - # end - # end - # - def with_driver_timeout(timeout) - current = @driver_timeout - driver.manage.timeouts.implicit_wait = timeout - yield - ensure - @driver_timeout = current - driver.manage.timeouts.implicit_wait = current - end - - # Prompts the user to press ENTER before resuming execution. - # - # For example: - # Context::wait - # # => echoes "Press ENTER to continue" and waits for user input - # - def self.wait - Readline.readline("Press ENTER to continue\n") - end - - # Constructs a Logger instance using +name+ as a hint for the logger - # type. - # - def self.load_logger(loggers, options) - if loggers.is_a? Array - return Loggers::CompositeLogger.new(options, loggers) - end - - name = loggers - - log_name = (name || 'null').downcase - - class_name = "#{log_name.capitalize}Logger" - - unless Loggers.const_defined? class_name.to_sym - raise NameError, - "Invalid logger '#{log_name}'" - end - - Loggers.const_get(class_name).new(options) - end - - # Adds an alias named +name+ to the specified +action+ with the - # arguments specified in +args+. - # - def add_alias(name, action, args) - @aliases[name] = { :action => action, :args => args } - end - - # Default action parsing strategy. - # - def self.parse_action_default(text, file = '<unknown>', line = 0) - data = text.split(' ', 2) - begin - args_text = data[1] ? data[1].strip : '' - args = [] - - unless args_text == '' - # col_sep must be a regex because String.split has a - # special case for a single space char (' ') that produced - # unexpected results (i.e. if line is '"a b"' the - # resulting array contains ["a b"]). - # - # ...but... - # - # CSV expects col_sep to be a string so we need to work - # some dark magic here. Basically we proxy the StringIO - # received by CSV to returns strings for which the split - # method does not fold the whitespaces. - # - args = CSV.new(StringIOProxy.new(args_text), { :col_sep => ' ' }) - .shift - .select { |a| a != nil } || [] - end - - { - :action => data[0].strip.downcase, - :args => args - } - rescue StandardError => e - raise "#{file} (line #{line+1}): #{e.message}" - end - end - - # Returns an executable Action object constructed from the specified - # arguments resolving action aliases. - # - # This method if part of the action execution chain and is intended - # for advanced use (e.g. in complex actions). To execute an Action - # directly, the #exec_action method is preferred. - # - def get_action(action, args, text = nil) - while (alias_action = @aliases[action]) - action = alias_action[:action] - args = alias_action[:args].map do |a| - a.gsub(/\$\{(\d+)(\*q?)?\}/) do |match| - # expand ${1} to args[0], ${2} to args[1], etc. - # expand ${4*} to "#{args[4]} #{args[5]} ..." - # expand ${4*q} to "\"#{args[4]}\" \"#{args[5]}\" ..." - idx = $1.to_i-1 - if $2 == nil - args[idx] || '' - else - range = args[idx..-1] - range = range.map { |arg| '"'+arg.gsub('"', '""')+'"' } if $2 == '*q' - range.join(' ') - end - end - end - end - - text = ([action] + args.map { |a| '"'+a.gsub('"', '""')+'"' }).join(' ') unless text - file = @variables['__FILE__'] - line = @variables['__LINE__'] - - Action.new(self, action, args, text, file, line) - end - - # Executes the specified action object injecting built-in variables. - # Note that the result returned by this method might be a lambda. - # If this is the case, a further +call+ method must be issued. - # - # This method if part of the action execution chain and is intended - # for advanced use (e.g. in complex actions). To execute an Action - # directly, the #exec_action method is preferred. - # - # For example: - # action = ctx.get_action("echo", ['Hi!'], 'echo "Hi!"') - # ret = ctx.exec_action_object(action) - # ret.call if ret.respond_to? :call - # - def exec_action_object(action) - action.execute - end - - # Prints the specified +error+ using the Logger configured and - # handling the verbose option. - # - # For example: - # begin - # # => some code here - # rescue StandardError => e - # @ctx.print_error e - # # => additional error handling code here - # end - # - def print_error(e, capture = true) - if @logger - @logger.log "#{e.message}\n", :error - else - puts e.message - end - if @options[:verbose] - p e - puts e.backtrace - end - if capture and @options[:capture] - with_vars(e.variables) do - exec_parsed_action('capture', [] , false) - e.variables['__CAPTURE__'] = @variables['__CAPTURE__'] - end - end - end - - # Returns the output path for +path+ accounting for the - # <tt>__OUTPUT__</tt> variable. - # - # For example: - # # assuming --output /mnt/disk - # - # ctx.output_path '/tmp/myfile.txt' - # # => returns '/tmp/myfile.txt' - # - # ctx.output_path 'myfile.txt' - # # => returns '/mnt/disk/myfile.txt' - # - def output_path(path) - unless Pathname.new(path).absolute? - output = @variables['__OUTPUT__'] - if output - Dir.mkdir output unless Dir.exists? output - path = File.join(output, path) - end - end - path - end - - # ======================================================================= # - # :section: Metadata - # ======================================================================= # - - # Returns an array with the names of every action available. - # - # For example: - # Context::actions - # # => [ "assert", "break", ... ] - # - def self.actions - _action_methods.map { |m| m.sub(/_action$/, '') } - end - - # Returns an array with the names of the arguments of the specified action. - # - # For example: - # Context::action_args 'assert' - # # => [ "selector", "text" ] - # - def self.action_args(action) - action += '_action' unless _action_methods.include? action - Action.public_instance_method(action).parameters.map { |att, name| name.to_s } - end - - # Returns an array with the names of every selector available. - # - # If +include_standard_selectors+ is +true+ (default behavior) both - # standard and custom selector are returned, otherwise only custom - # selectors are returned. - # - # For example: - # Context::selectors - # # => [ "class", "id", ... ] - # - def self.selectors(include_standard_selectors = true) - ret = Selector.public_instance_methods(false).map { |a| a.to_s.sub(/_selector$/, '') } - if include_standard_selectors - ret += Selenium::WebDriver::SearchContext::FINDERS.map { |k,v| k.to_s } - end - ret - end - - # Returns an array with the names of every logger available. - # - # For example: - # Context::loggers - # # => [ "null", "bash", ... ] - # - def self.loggers - Loggers.constants.map { |l| l.to_s.downcase.sub(/logger$/, '') } - end - - # Returns an array with the names of every parser available. - # - # For example: - # Context::parsers - # # => [ "default", "html", ... ] - # - def self.parsers - (Parser.public_instance_methods(false) \ - - ParserModule.public_instance_methods(false)) - .map { |p| p.to_s } - end - - # Returns the maximum size in characters of an action name. - # - # This method is useful to pretty print lists of actions - # - # For example: - # # assuming actions = [ "echo", "assert", "tryload" ] - # Context::max_action_name_size - # # => 7 - def self.max_action_name_size - actions.inject(0) { |s,a| a.size > s ? a.size : s } - end - - # ======================================================================= # - # :section: Variable manipulation methods - # ======================================================================= # - - # Recursively replaces occurencies of variable expansions in +s+ with the - # corresponding variable value. - # - # The variable expansion expression format is: - # '${variable_name}' - # - # For example: - # ctx.variables = { 'a' => '1', 'b' => '2', 'c' => 'a' } - # ctx.expand '${a}' # => '1' - # ctx.expand '${b}' # => '2' - # ctx.expand '${c}' # => 'a' - # ctx.expand '${${c}}' # => '1' - # - def expand(s) - result = @variables.inject(s) do |s,kv| - s = s.gsub(/\$\{#{kv[0]}\}/, kv[1].to_s) - end - result = expand(result) if result != s - result - end - - # Temporarily alter the value of context variables. - # - # This method alters the value of the variables specified in the +vars+ - # hash for the duration of the given +block+. When the +block+ completes, - # the original value of the context variables is restored. - # - # For example: - # ctx.variables = { 'a' => '1', 'b' => '2', c => 'a' } - # ctx.with_vars({ 'a' => '10', d => '20' }) do - # p ctx.variables - # # => {"a"=>"10", "b"=>"2", "c"=>"a", "d"=>"20"} - # end - # p ctx.variables - # # => {"a"=>"1", "b"=>"2", "c"=>"a"} - # - def with_vars(vars) - current = @variables - @variables = @variables.merge(vars) - ret_vars = nil - - ret = yield - - returned = @variables['__RETURN__'] - if returned == ['*'] - ret_vars = @variables.clone - ret_vars.delete '__RETURN__' - elsif returned != nil - ret_vars = @variables.select { |k,v| returned.include? k } - end - rescue StandardError => e - e.instance_variable_set "@variables", @variables - def e.variables - @variables - end - raise - ensure - @variables = current - @variables.merge!(ret_vars) if ret_vars - ret - end - - private - def self._action_methods - (Action.public_instance_methods(false) \ - - ActionModule.public_instance_methods(false)) - .map { |a| a.to_s } - end - - def _load_driver - @driver = Selenium::WebDriver.for(@driver_name, @options[:driver_opt]) - @driver.manage.timeouts.implicit_wait = 1 - @driver_timeout = 1 - end - - def _load_extensions(dirs) - dirs.each do |d| - d = File.join(Dir.pwd, d) unless Dir.exists? d - d = File.absolute_path(d) - Dir[File.join(d, '**', '*.rb')].each { |file| require file } - end - end - - # ======================================================================= # - # Hacks required to overcome the String#split(' ') behavior of folding the - # space characters, coupled with CSV not supporting a regex as :col_sep. - - # Same as a common String except that split(' ') behaves as split(/\s/). - class StringProxy # :nodoc: - def initialize(s) - @s = s - end - - def method_missing(method, *args, &block) - args[0] = /\s/ if method == :split and args.size > 0 and args[0] == ' ' - ret = @s.send(method, *args, &block) - end - end - - # Same as a common StringIO except that get(sep) returns a StringProxy - # instead of a regular string. - class StringIOProxy # :nodoc: - def initialize(s) - @s = StringIO.new(s) - end - - def method_missing(method, *args, &block) - ret = @s.send(method, *args, &block) - return ret unless method == :gets and args.size == 1 - StringProxy.new(ret) - end - end - # ======================================================================= # - end -end \ No newline at end of file +#-- +# Copyright (c) 2014 Patricio Zavolinsky +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +#++ + +require 'selenium-webdriver' +require 'readline' +require 'csv' +require 'pathname' + +# Load dependencies and extensions without leaking dir into the global scope +lambda do + dir = File.expand_path(File.dirname(__FILE__)) + Dir[File.join(dir, '*.rb')].each { |file| require file } + Dir[File.join(dir, '..', 'actions' , '*.rb')].each { |file| require file } + Dir[File.join(dir, '..', 'selectors', '*.rb')].each { |file| require file } + Dir[File.join(dir, '..', 'loggers' , '*.rb')].each { |file| require file } + Dir[File.join(dir, '..', 'parsers' , '*.rb')].each { |file| require file } +end.call + +# Bauxite Namespace +module Bauxite + # The Main test context. This class includes state and helper functions + # used by clients execute tests and by actions and selectors to interact + # with the test engine (i.e. Selenium WebDriver). + # + # === Context variables + # Context variables are a key/value pairs scoped to the a test context. + # + # Variables can be set using different actions. For example: + # - Action#set sets a variable to a literal string. + # - Action#store sets a variable to the value of an element in the page. + # - Action#exec sets a variable to the output of an external command + # (i.e. stdout). + # - Action#js sets a variable to the result of Javascript command. + # - Action#replace sets a variable to the result of doing a + # find-and-replace operation on a literal. + # + # Variables can be expanded in every Action argument (e.g. selectors, + # texts, expressions, etc.). To obtain the value of a variable through + # variable expansion the following syntax must be used: + # ${var_name} + # + # For example: + # set field "greeting" + # set name "John" + # write "${field}_textbox" "Hi, my name is ${name}!" + # click "${field}_button" + # + # === Variable scope + # When the main test starts (via the #start method), the test is bound + # to the global scope. The variables defined in the global scope are + # available to every test Action. + # + # The global scope can have nested variable scopes created by special + # actions. The variables defined in a scope +A+ are only available to that + # scope and scopes nested within +A+. + # + # Every time an Action loads a file, a new nested scope is created. + # File-loading actions include: + # - Action#load + # - Action#tryload + # - Action#ruby + # - Action#test + # + # A nested scope can bubble variables to its parent scope with the special + # action: + # - Action#return_action + # + # === Built-in variable + # Bauxite has a series of built-in variables that provide information of + # the current test context and allow dynamic constomizations of the test + # behavior. + # + # The built-in variables are: + # [<tt>__FILE__</tt>] The file where the current action is defined. + # [<tt>__DIR__</tt>] The directory where <tt>__FILE__</tt> is. + # [<tt>__SELECTOR__</tt>] The default selector used when the selector + # specified does not contain an <tt>=</tt> + # character. + # [<tt>__DEBUG__</tt>] Set to true if the current action is being executed + # by the debug console. + # [<tt>__RETURN__</tt>] Used internally by Action#return_action to indicate + # which variables should be returned to the parent + # scope. + # + # In general, variables surrounded by double underscores and variables + # whose names are only numbers are reserved for Bauxite and should not be + # used as part of a functional test. The obvious exception is when trying + # to change the test behavior by changing the built-in variables. + # + class Context + # Logger instance. + attr_reader :logger + + # Test options. + attr_reader :options + + # Context variables. + attr_accessor :variables + + # Test containers. + attr_accessor :tests + + # Constructs a new test context instance. + # + # +options+ is a hash with the following values: + # [:driver] selenium driver symbol (defaults to +:firefox+) + # [:timeout] selector timeout in seconds (defaults to +10s+) + # [:logger] logger implementation name without the 'Logger' suffix + # (defaults to 'null' for Loggers::NullLogger). + # [:verbose] if +true+, show verbose error information (e.g. + # backtraces) if an error occurs (defaults to +false+) + # [:debug] if +true+, break into the #debug console if an error occurs + # (defaults to +false+) + # [:wait] if +true+, call ::wait before stopping the test engine with + # #stop (defaults to +false+) + # [:extensions] an array of directories that contain extensions to be + # loaded + # + def initialize(options) + @options = options + @driver_name = (options[:driver] || :firefox).to_sym + @variables = { + '__TIMEOUT__' => (options[:timeout] || 10).to_i, + '__DEBUG__' => false, + '__SELECTOR__' => options[:selector] || 'sid', + '__OUTPUT__' => options[:output], + '__DIR__' => File.absolute_path(Dir.pwd) + } + @aliases = {} + @tests = [] + + client = Selenium::WebDriver::Remote::Http::Default.new + client.timeout = (@options[:open_timeout] || 60).to_i + @options[:driver_opt] = {} unless @options[:driver_opt] + @options[:driver_opt][:http_client] = client + + _load_extensions(options[:extensions] || []) + + @logger = Context::load_logger(options[:logger], options[:logger_opt]) + + @parser = Parser.new(self) + end + + # Starts the test engine and executes the actions specified. If no action + # was specified, returns without stopping the test engine (see #stop). + # + # For example: + # lines = [ + # 'open "http://www.ruby-lang.org"', + # 'write "name=q" "ljust"', + # 'click "name=sa"', + # 'break' + # ] + # ctx.start(lines) + # # => navigates to www.ruby-lang.org, types ljust in the search box + # # and clicks the "Search" button. + # + def start(actions = []) + return unless actions.size > 0 + begin + actions.each do |action| + begin + break if exec_action(action) == :break + rescue StandardError => e + print_error(e) + raise unless @options[:debug] + debug + end + end + ensure + stop + end + end + + # Stops the test engine and starts a new engine with the same provider. + # + # For example: + # ctx.reset_driver + # => closes the browser and opens a new one + # + def reset_driver + @driver.quit if @driver + @driver = nil + end + + # Stops the test engine. + # + # Calling this method at the end of the test is mandatory if #start was + # called without +actions+. + # + # Note that the recommeneded way of executing tests is by passing a list + # of +actions+ to #start instead of using the #start / #stop pattern. + # + # For example: + # ctx.start(:firefox) # => opens firefox + # + # # test stuff goes here + # + # ctx.stop # => closes firefox + # + def stop + Context::wait if @options[:wait] + begin + @logger.finalize(self) + rescue StandardError => e + print_error(e) + raise + ensure + @driver.quit if @driver + end + end + + # Finds an element by +selector+. + # + # The element found is yielded to the given +block+ (if any) and returned. + # + # Note that the recommeneded way to call this method is by passing a + # +block+. This is because the method ensures that the element context is + # maintained for the duration of the +block+ but it makes no guarantees + # after the +block+ completes (the same applies if no +block+ was given). + # + # For example: + # ctx.find('css=.my_button') { |element| element.click } + # ctx.find('css=.my_button').click + # + # For example (where using a +block+ is mandatory): + # ctx.find('frame=|myframe|css=.my_button') { |element| element.click } + # # => .my_button clicked + # + # ctx.find('frame=|myframe|css=.my_button').click + # # => error, cannot click .my_button (no longer in myframe scope) + # + def find(selector, &block) # yields: element + with_timeout Selenium::WebDriver::Error::NoSuchElementError do + Selector.new(self, @variables['__SELECTOR__']).find(selector, &block) + end + end + + # Test engine driver instance (Selenium WebDriver). + def driver + _load_driver unless @driver + @driver + end + + # Breaks into the debug console. + # + # For example: + # ctx.debug + # # => this breaks into the debug console + def debug + exec_parsed_action('debug', [], false) + end + + # Returns the value of the specified +element+. + # + # This method takes into account the type of element and selectively + # returns the inner text or the value of the +value+ attribute. + # + # For example: + # # assuming <input type='text' value='Hello' /> + # # <span id='label'>World!</span> + # + # ctx.get_value(ctx.find('css=input[type=text]')) + # # => returns 'Hello' + # + # ctx.get_value(ctx.find('label')) + # # => returns 'World!' + # + def get_value(element) + if ['input','select','textarea'].include? element.tag_name.downcase + element.attribute('value') + else + element.text + end + end + + # ======================================================================= # + # :section: Advanced Helpers + # ======================================================================= # + + # Executes the specified action string handling errors, logging and debug + # history. + # + # If +log+ is +true+, log the action execution (default behavior). + # + # For example: + # ctx.exec_action 'open "http://www.ruby-lang.org"' + # # => navigates to www.ruby-lang.org + # + def exec_action(text) + data = Context::parse_action_default(text, '<unknown>', 0) + exec_parsed_action(data[:action], data[:args], true, text) + end + + # Executes the specified +file+. + # + # For example: + # ctx.exec_file('file') + # # => executes every action defined in 'file' + # + def exec_file(file) + current_dir = @variables['__DIR__' ] + current_file = @variables['__FILE__'] + current_line = @variables['__LINE__'] + + @parser.parse(file) do |action, args, text, file, line| + @variables['__DIR__'] = File.absolute_path(File.dirname(file)) + @variables['__FILE__'] = file + @variables['__LINE__'] = line + break if exec_parsed_action(action, args, true, text) == :break + end + + @variables['__DIR__' ] = current_dir + @variables['__FILE__'] = current_file + @variables['__LINE__'] = current_line + end + + # Executes the specified action handling errors, logging and debug + # history. + # + # If +log+ is +true+, log the action execution (default behavior). + # + # This method if part of the action execution chain and is intended + # for advanced use (e.g. in complex actions). To execute an Action + # directly, the #exec_action method is preferred. + # + # For example: + # ctx.exec_action 'open "http://www.ruby-lang.org"' + # # => navigates to www.ruby-lang.org + # + def exec_parsed_action(action, args, log = true, text = nil) + action = get_action(action, args, text) + ret = nil + if log + @logger.log_cmd(action) do + Readline::HISTORY << action.text + ret = exec_action_object(action) + end + else + ret = exec_action_object(action) + end + + if ret.respond_to? :call # delayed actions (after log_cmd) + ret.call + else + ret + end + rescue Selenium::WebDriver::Error::UnhandledAlertError + raise Bauxite::Errors::AssertionError, "Unexpected modal present" + end + + # Executes the given block retrying for at most <tt>${__TIMEOUT__}</tt> + # seconds. Note that this method does not take into account the time it + # takes to execute the block itself. + # + # For example + # ctx.with_timeout StandardError do + # ctx.find ('element_with_delay') do |e| + # # do something with e + # end + # end + # + def with_timeout(*error_types) + stime = Time.new + timeout ||= stime + @variables['__TIMEOUT__'].to_i + yield + rescue *error_types => e + t = Time.new + rem = timeout - t + raise if rem < 0 + + @logger.progress(rem.round) + + sleep(1.0/10.0) if (t - stime).to_i < 1 + retry + end + + # Executes the given block using the specified driver +timeout+. + # + # Note that the driver +timeout+ is the time (in seconds) Selenium + # will wait for a specific element to appear in the page (using any + # of the available Selector strategies). + # + # For example + # ctx.with_driver_timeout 0.5 do + # ctx.find ('find_me_quickly') do |e| + # # do something with e + # end + # end + # + def with_driver_timeout(timeout) + current = @driver_timeout + driver.manage.timeouts.implicit_wait = timeout + yield + ensure + @driver_timeout = current + driver.manage.timeouts.implicit_wait = current + end + + # Prompts the user to press ENTER before resuming execution. + # + # For example: + # Context::wait + # # => echoes "Press ENTER to continue" and waits for user input + # + def self.wait + Readline.readline("Press ENTER to continue\n") + end + + # Constructs a Logger instance using +name+ as a hint for the logger + # type. + # + def self.load_logger(loggers, options) + if loggers.is_a? Array + return Loggers::CompositeLogger.new(options, loggers) + end + + name = loggers + + log_name = (name || 'null').downcase + + class_name = "#{log_name.capitalize}Logger" + + unless Loggers.const_defined? class_name.to_sym + raise NameError, + "Invalid logger '#{log_name}'" + end + + Loggers.const_get(class_name).new(options) + end + + # Adds an alias named +name+ to the specified +action+ with the + # arguments specified in +args+. + # + def add_alias(name, action, args) + @aliases[name] = { :action => action, :args => args } + end + + # Default action parsing strategy. + # + def self.parse_action_default(text, file = '<unknown>', line = 0) + data = text.split(' ', 2) + begin + args_text = data[1] ? data[1].strip : '' + args = [] + + unless args_text == '' + # col_sep must be a regex because String.split has a + # special case for a single space char (' ') that produced + # unexpected results (i.e. if line is '"a b"' the + # resulting array contains ["a b"]). + # + # ...but... + # + # CSV expects col_sep to be a string so we need to work + # some dark magic here. Basically we proxy the StringIO + # received by CSV to returns strings for which the split + # method does not fold the whitespaces. + # + args = CSV.new(StringIOProxy.new(args_text), { :col_sep => ' ' }) + .shift + .select { |a| a != nil } || [] + end + + { + :action => data[0].strip.downcase, + :args => args + } + rescue StandardError => e + raise "#{file} (line #{line+1}): #{e.message}" + end + end + + # Returns an executable Action object constructed from the specified + # arguments resolving action aliases. + # + # This method if part of the action execution chain and is intended + # for advanced use (e.g. in complex actions). To execute an Action + # directly, the #exec_action method is preferred. + # + def get_action(action, args, text = nil) + while (alias_action = @aliases[action]) + action = alias_action[:action] + args = alias_action[:args].map do |a| + a.gsub(/\$\{(\d+)(\*q?)?\}/) do |match| + # expand ${1} to args[0], ${2} to args[1], etc. + # expand ${4*} to "#{args[4]} #{args[5]} ..." + # expand ${4*q} to "\"#{args[4]}\" \"#{args[5]}\" ..." + idx = $1.to_i-1 + if $2 == nil + args[idx] || '' + else + range = args[idx..-1] + range = range.map { |arg| '"'+arg.gsub('"', '""')+'"' } if $2 == '*q' + range.join(' ') + end + end + end + end + + text = ([action] + args.map { |a| '"'+a.gsub('"', '""')+'"' }).join(' ') unless text + file = @variables['__FILE__'] + line = @variables['__LINE__'] + + Action.new(self, action, args, text, file, line) + end + + # Executes the specified action object injecting built-in variables. + # Note that the result returned by this method might be a lambda. + # If this is the case, a further +call+ method must be issued. + # + # This method if part of the action execution chain and is intended + # for advanced use (e.g. in complex actions). To execute an Action + # directly, the #exec_action method is preferred. + # + # For example: + # action = ctx.get_action("echo", ['Hi!'], 'echo "Hi!"') + # ret = ctx.exec_action_object(action) + # ret.call if ret.respond_to? :call + # + def exec_action_object(action) + action.execute + end + + # Prints the specified +error+ using the Logger configured and + # handling the verbose option. + # + # For example: + # begin + # # => some code here + # rescue StandardError => e + # @ctx.print_error e + # # => additional error handling code here + # end + # + def print_error(e, capture = true) + if @logger + @logger.log "#{e.message}\n", :error + else + puts e.message + end + if @options[:verbose] + p e + puts e.backtrace + end + if capture and @options[:capture] + with_vars(e.variables) do + exec_parsed_action('capture', [] , false) + e.variables['__CAPTURE__'] = @variables['__CAPTURE__'] + end + end + end + + # Returns the output path for +path+ accounting for the + # <tt>__OUTPUT__</tt> variable. + # + # For example: + # # assuming --output /mnt/disk + # + # ctx.output_path '/tmp/myfile.txt' + # # => returns '/tmp/myfile.txt' + # + # ctx.output_path 'myfile.txt' + # # => returns '/mnt/disk/myfile.txt' + # + def output_path(path) + unless Pathname.new(path).absolute? + output = @variables['__OUTPUT__'] + if output + Dir.mkdir output unless Dir.exists? output + path = File.join(output, path) + end + end + path + end + + # ======================================================================= # + # :section: Metadata + # ======================================================================= # + + # Returns an array with the names of every action available. + # + # For example: + # Context::actions + # # => [ "assert", "break", ... ] + # + def self.actions + _action_methods.map { |m| m.sub(/_action$/, '') } + end + + # Returns an array with the names of the arguments of the specified action. + # + # For example: + # Context::action_args 'assert' + # # => [ "selector", "text" ] + # + def self.action_args(action) + action += '_action' unless _action_methods.include? action + Action.public_instance_method(action).parameters.map { |att, name| name.to_s } + end + + # Returns an array with the names of every selector available. + # + # If +include_standard_selectors+ is +true+ (default behavior) both + # standard and custom selector are returned, otherwise only custom + # selectors are returned. + # + # For example: + # Context::selectors + # # => [ "class", "id", ... ] + # + def self.selectors(include_standard_selectors = true) + ret = Selector.public_instance_methods(false).map { |a| a.to_s.sub(/_selector$/, '') } + if include_standard_selectors + ret += Selenium::WebDriver::SearchContext::FINDERS.map { |k,v| k.to_s } + end + ret + end + + # Returns an array with the names of every logger available. + # + # For example: + # Context::loggers + # # => [ "null", "bash", ... ] + # + def self.loggers + Loggers.constants.map { |l| l.to_s.downcase.sub(/logger$/, '') } + end + + # Returns an array with the names of every parser available. + # + # For example: + # Context::parsers + # # => [ "default", "html", ... ] + # + def self.parsers + (Parser.public_instance_methods(false) \ + - ParserModule.public_instance_methods(false)) + .map { |p| p.to_s } + end + + # Returns the maximum size in characters of an action name. + # + # This method is useful to pretty print lists of actions + # + # For example: + # # assuming actions = [ "echo", "assert", "tryload" ] + # Context::max_action_name_size + # # => 7 + def self.max_action_name_size + actions.inject(0) { |s,a| a.size > s ? a.size : s } + end + + # ======================================================================= # + # :section: Variable manipulation methods + # ======================================================================= # + + # Recursively replaces occurencies of variable expansions in +s+ with the + # corresponding variable value. + # + # The variable expansion expression format is: + # '${variable_name}' + # + # For example: + # ctx.variables = { 'a' => '1', 'b' => '2', 'c' => 'a' } + # ctx.expand '${a}' # => '1' + # ctx.expand '${b}' # => '2' + # ctx.expand '${c}' # => 'a' + # ctx.expand '${${c}}' # => '1' + # + def expand(s) + result = @variables.inject(s) do |s,kv| + s = s.gsub(/\$\{#{kv[0]}\}/, kv[1].to_s) + end + result = expand(result) if result != s + result + end + + # Temporarily alter the value of context variables. + # + # This method alters the value of the variables specified in the +vars+ + # hash for the duration of the given +block+. When the +block+ completes, + # the original value of the context variables is restored. + # + # For example: + # ctx.variables = { 'a' => '1', 'b' => '2', c => 'a' } + # ctx.with_vars({ 'a' => '10', d => '20' }) do + # p ctx.variables + # # => {"a"=>"10", "b"=>"2", "c"=>"a", "d"=>"20"} + # end + # p ctx.variables + # # => {"a"=>"1", "b"=>"2", "c"=>"a"} + # + def with_vars(vars) + current = @variables + @variables = @variables.merge(vars) + ret_vars = nil + + ret = yield + + returned = @variables['__RETURN__'] + if returned == ['*'] + ret_vars = @variables.clone + ret_vars.delete '__RETURN__' + elsif returned != nil + ret_vars = @variables.select { |k,v| returned.include? k } + end + rescue StandardError => e + e.instance_variable_set "@variables", @variables + def e.variables + @variables + end + raise + ensure + @variables = current + @variables.merge!(ret_vars) if ret_vars + ret + end + + private + def self._action_methods + (Action.public_instance_methods(false) \ + - ActionModule.public_instance_methods(false)) + .map { |a| a.to_s } + end + + def _load_driver + @driver = Selenium::WebDriver.for(@driver_name, @options[:driver_opt]) + @driver.manage.timeouts.implicit_wait = 1 + @driver_timeout = 1 + end + + def _load_extensions(dirs) + dirs.each do |d| + d = File.join(Dir.pwd, d) unless Dir.exists? d + d = File.absolute_path(d) + Dir[File.join(d, '**', '*.rb')].each { |file| require file } + end + end + + # ======================================================================= # + # Hacks required to overcome the String#split(' ') behavior of folding the + # space characters, coupled with CSV not supporting a regex as :col_sep. + + # Same as a common String except that split(' ') behaves as split(/\s/). + class StringProxy # :nodoc: + def initialize(s) + @s = s + end + + def method_missing(method, *args, &block) + args[0] = /\s/ if method == :split and args.size > 0 and args[0] == ' ' + ret = @s.send(method, *args, &block) + end + end + + # Same as a common StringIO except that get(sep) returns a StringProxy + # instead of a regular string. + class StringIOProxy # :nodoc: + def initialize(s) + @s = StringIO.new(s) + end + + def method_missing(method, *args, &block) + ret = @s.send(method, *args, &block) + return ret unless method == :gets and args.size == 1 + StringProxy.new(ret) + end + end + # ======================================================================= # + end +end