#-- # 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' # 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: # [__FILE__] The file where the current action is defined. # [__DIR__] The directory where __FILE__ is. # [__SELECTOR__] The default selector used when the selector # specified does not contain an = # character. # [__DEBUG__] Set to true if the current action is being executed # by the debug console. # [__RETURN__] 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 # Test engine driver instance (Selenium WebDriver). attr_reader :driver # 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] } @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 = []) _load_driver return unless actions.size > 0 begin actions.each do |action| begin exec_action(action) 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 _load_driver 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] @driver.quit 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 # Breaks into the debug console. # # For example: # ctx.debug # # => this breaks into the debug console def debug exec_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 # # World! # # 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, log = true, file = '', line = 0) data = Context::parse_action_default(text, file, line) exec_parsed_action(data[:action], data[:args], log, text, file, line) end # Executes the specified +file+. # # For example: # ctx.exec_file('file') # # => executes every action defined in 'file' # def exec_file(file) @parser.parse(file) do |action, args, text, file, line| exec_parsed_action(action, args, true, text, file, line) end 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, file = nil, line = nil) action = get_action(action, args, text, file, line) 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 ret.call if ret.respond_to? :call # delayed actions (after log_cmd) end # Executes the given block retrying for at most ${__TIMEOUT__} # 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(name, options) 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 = '', 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, file = nil, line = 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__'] unless file line = @variables['__LINE__'] unless 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) # Inject built-in variables file = action.file dir = (File.exists? file) ? File.dirname(file) : Dir.pwd @variables['__FILE__'] = file @variables['__DIR__'] = File.absolute_path(dir) 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) if @logger @logger.log "#{e.message}\n", :error else puts e.message end if @options[:verbose] p e puts e.backtrace end 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) yield ensure @variables = current 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