diff --git a/dev/mods.rb b/dev/mods.rb new file mode 100644 index 0000000..808554e --- /dev/null +++ b/dev/mods.rb @@ -0,0 +1,265 @@ +class ElementContainer + attr_reader :target + + class << self + # Implement the same argument parsing the Watir::HTMLElement does because we're + # doing a pass-through. + private + def parse_args(args) + case args.length + when 2 + return { args[0] => args[1] } + when 1 + obj = args.first + return obj if obj.kind_of? Hash + when 0 + return {} + end + end + public + + WATIR_METHODS.each do |mth| + define_method(mth) do |name=nil, *args| + el(name) { |b| b.send(mth, parse_args(args.flatten)) } + end + end + + # For ElementContainer. + def el(name, &block) + @page_elements ||= [] + @page_elements << name.to_sym + + define_method(name) do + begin + block.call(@target) + rescue(Watir::Exception::UnknownObjectException) => e + tmp = page + + if tmp == @most_recent_page + raise e + else + @most_recent_page = tmp + block.call(@target) + end + end + end + end + end # self + + def initialize(element) + @target = element + end + + def nokogiri + Nokogiri::HTML(html) + end + + # For page widget code. + def method_missing(sym, *args, &block) + if @target.respond_to? sym + if @target.is_a? Watir::ElementCollection + @target.map { |x| self.class.new(x) }.send(sym, *args, &block) + else + @target.send(sym, *args, &block) + end + else + super + end + end +end + +module PageObject + + module PageClassMethods + + # Adds all of the Watir DOM methods as class-level methods that can be used in + # place of Page::element/el. Example: + # + # # Old way (Still supported.) + # el(:foo_div) { |b| b.div(:id, 'foo') } + # + # # The new methods mirror the behavior of the Watir::Browser methods. You can + # # either provide :how and :what arguments or a hash of values. The following + # # two examples are functionally equivalent: + # div(:foo_div, :id:, 'foo') # :how and :what arguments to identify something one way. + # div( :foo_div, :id: 'foo') # Hash argument to identify something multiple ways. + # WATIR_METHODS.each do |mth| + # define_method(mth) do |name=nil, *args| + # el(name) { |b| b.send(mth, parse_args(args.flatten)) } + # end + # end + + WATIR_METHODS.each do |mth| + define_method(mth) do |name=nil, *args, &block| + if block + element_container(name, mth, *args, &block) + else + el(name) { |b| b.send(mth, parse_args(args.flatten)) } + end + end + end + + # private + def element_container(name, type, *args, &block) + tmpklass = Class.new(ElementContainer) do + self.class_eval(&block) if block_given? + end + + cname = name.to_s.camelcase + 'Container' + const_set(cname, tmpklass) unless const_defined? cname + + @page_elements ||= [] + @page_elements << name.to_sym + + define_method(name) do + self.class.const_get(cname).send(:new, @browser.send(type, *args)) + end + end + # public + + def section(name, klass = Section, &block) + tmpklass = Class.new(klass) do + self.class_eval(&block) if block_given? + end + const_set(name.to_s.camelcase, tmpklass) unless const_defined? name.to_s.camelcase + + define_method(name.to_s.underscore) do + tmpklass.new(page = self) + end + end + + # Implement the same argument parsing the Watir::HTMLElement does because we're + # doing a pass-through. + private + def parse_args(args) + case args.length + when 2 + return { args[0] => args[1] } + when 1 + obj = args.first + return obj if obj.kind_of? Hash + when 0 + return {} + end + end + public + + def widget_method(method_name, widget_symbol, widget_method, target_element) + define_method(method_name) do |*args, &block| + self.class.const_get(widget_symbol.to_s.camelize) + .new(@site, @site.send(target_element)) + .send(widget_method, *args, &block) + # widget_symbol.to_s.camelize.constantize.new(@site, @site.send(target_element).send(widget_method, *args, &block) + end + end + + end # End PageClassMethods module. + + module PageInstanceMethods + + # EXPERIMENTAL, only tested with Watir. A simple mechanism to assist in cases where you need to + # update a page that has a form. This method takes a Hash argument. Keys should be the names of + # page element methods. Values should be one of the following: + # -String: The method will assume that you're trying to set a text field or select a value in a + # select list. + # -Symbol: The method will assume that you're trying to set or clear something and will pass the + # Symbol you've specified along to the thing you're accessing. + # -Regexp: The method will assume you're trying to select something in a select list and will + # try to do that using the Regexp that has been specified. + # + # Example: You have a form on a page that has text fields to specify a first name, last name, + # email address and whether or not to subscribe to a company newsletter. You've defined each of + # these HTML elements in your page class. You could use this method to update the form on this + # page in the following manner: + # + # page.update_page( + # first_name: 'Jar Jar', + # last_name: 'Binks', + # subscribe_to_newsletter: :set + # ) + # + # Note that there's no call to submit the form in this example although you could include that + # in the method call if the option to submit has been defined as a page element in the page class. + # This is by design. The intent is to provide a base setter method for the page that can be + # wrapped up in a higher-level 'create' or 'edit' method for the page because there may be some + # situations where you don't actually want to submit the form after populating it. + def update_page(args={}) # test + failed = [] + args.each do |k, v| + begin + k = k.to_sym + if page_elements.include?(k) + Watir::Wait.until(15) { self.send(k).present? } + tmp = self.send(k) + # tmp.when_present(15) if tmp.respond_to? :when_present + if tmp.is_a? Watir::WhenPresentDecorator + html_element = tmp.instance_variable_get(:@element) + else + html_element = tmp + end + + if [Watir::Alert, Watir::FileField, Watir::TextField, Watir::TextArea].include? html_element.class + html_element.set v + elsif [Watir::Select].include? html_element.class + html_element.select v + elsif [Watir::Anchor, Watir::Button].include? html_element.class + case v + when Symbol + html_element.send v + when TrueClass + html_element.click + when FalseClass + # Do nothing here. + else + raise ArgumentError, "Unsupported argument for #{html_element.class}: '#{v}'" + end + elsif html_element.is_a?(Watir::RadioCollection) + rb = html_element.to_a.find do |r| + r.text =~ /#{Regexp.escape(v)}/i || r.parent.text =~ /#{Regexp.escape(v)}/i + end + + if rb + rb.click + else + raise "No matching radio button could be detected for '#{val}' for #{html_element}." + end + else + case v + when Symbol + html_element.send v + when TrueClass + html_element.set + when FalseClass + html_element.clear + else + raise ArgumentError, "Unsupported argument for #{html_element.class}: '#{v}'" + end + end + else + # Temporary band-aid to support widgets. + tmp = send(k) + + if tmp.is_a?(Widget) || tmp.is_a?(ElementContainer) + if tmp.respond_to?(:update) + tmp.update(*v) + else + raise "Cannot update #{tmp.class} (an update method must be added.)" + end + else + raise "Cannot update #{tmp.class}." + end + end + rescue Watir::Exception::ObjectDisabledException, Watir::Exception::UnknownObjectException => e + unless failed.include?(k) + puts "Rescued #{e.class} when trying to update #{k}. Sleeping 10 seconds and then trying again." + failed << k + sleep 10 + redo + end + end + end + sleep 1 + args + end + end # End PageInstanceMethods module. +end diff --git a/dev/widgets.rb b/dev/widgets.rb new file mode 100644 index 0000000..95d31fb --- /dev/null +++ b/dev/widgets.rb @@ -0,0 +1,296 @@ +module WidgetMethods +end + +# Allows the page object developer to encapsulate common web application features +# into a "widget" that can be reused across multiple pages. Let's say that a +# web application has a search widget that is used in 11 of the application's pages. +# With a modern web app all of those search widgets will likely be implemented +# in a common way, with a similar or identical structure in the HTML. The widget +# would look something like this: +# +# class SearchWidget < Widget +# text_field :query, id: 'q' +# button :search_button, name: 'Search' +# +# def search(search_query) +# query.set search_query +# search_button.click +# end +# +# def clear +# query.set '' +# search_button.click +# end +# end +# +# Once the widget has been defined, it can be included in a page object definition +# like this: +# +# class SomePage < SomeSite::Page +# set_url 'some_page' +# search_widget :search_for_foo, :div, class: 'search-div' +# end +# +# The search widget can then be accessed like this when working with the site: +# site.some_page.search_for_foo 'some search term' +# site.search_for_foo.clear +# +# Widgets can be embedded in other widgets, but in that case, the arguments for +# accessing the child widget need to be RELATIVE to the parent widget. For example: +# +# # Generic link menu, you hover over it and one or more links are displayed. +# class LinkMenu < Widget +# end +# +# # Card widget that uses the link_menu widget. In this case, link_menu widget +# # arguments will be used to find a div a div with class == 'card-action-links' +# # WITHIN the card itself. This ensures that, if there are multiple cards +# # on the page that have link_menus, the CORRECT link_menu will be accessed +# # rather than one for some other card widget. +# class Card < Widget +# link_menu :card_menu, :div, class: 'card-action-links' +# end +class Widget + attr_reader :site, :browser, :type, :args, :target + + class << self + include WidgetMethods + + # Adds class-level Watir DOM methods to the widget for defining page + # elements. + WATIR_METHODS.each do |mth| + define_method(mth) do |name=nil, *args, &block| + if block + element_container(name, mth, *args, &block) + else + el(name) { |b| b.send(mth, parse_args(args.flatten)) } + end + end + end + + # private + # def parse_args(args) + # case args.length + # when 2 + # return { args[0] => args[1] } + # when 1 + # obj = args.first + # return obj if obj.kind_of? Hash + # when 0 + # return {} + # end + # end + # public + # + # WATIR_METHODS.each do |mth| + # define_method(mth) do |name=nil, *args| + # send(mth, parse_args(args.flatten)) + # end + # end + + # - Don't allow the user to create a widget with a name that matches a DOM + # element. + # + # - Don't allow the user to create a widget method that references a + # collection (because this will be done automatically.) + tmp = name.to_s.underscore.to_sym + if WATIR_METHODS.include?(name.to_s.underscore.to_sym) + raise "#{name} cannot be used as a widget name, as the methodized version of the class name (#{name.to_s.underscore} conflicts with a Watir DOM method.)" + elsif Watir::Browser.methods.include?(name.to_s.underscore.to_sym) + raise "#{name} cannot be used as a widget name, as the methodized version of the class name (#{name.to_s.underscore} conflicts with a Watir::Browser method.)" + end + + if tmp =~ /.*s+/ + raise "Invalid widget type :#{tmp}. You can create a widget for the DOM object but it must be for :#{tmp.singularize} (:#{tmp} will be created automatically.)" + end + end # Self. + + extend Forwardable + + # Creates class methods for widgets. These methods are then used to reference + # the widget when defining page objects. For each widget that gets defined, + # singular and pluralized versions of the method will be created, one for an + # individual instance of the widget and another for a collection. For example, + # defining a Foobar widget will result in 2 class methods that can be used when + # defining page objects called :foobar and :foobars. Either one or both of those + # could be used when defining a page object. + def self.inherited(subclass) + name_string = subclass.name.demodulize.underscore + pluralized_name_string = name_string.pluralize + + if name_string == pluralized_name_string + raise ArgumentError, "When defining a new widget, define the singular version only (Plural case will be handled automatically.)" + end + + # tmp = Object.const_set(pluralized_name_string, subclass) + + # Adds class-level widget methods. + #[name_string, pluralized_name_string].each do |method_name| + WidgetMethods.send(:define_method, name_string) do |method_name, dom_type, *args, &block| + if block_given? + subclass.class_eval { block.call } + end + + define_method(method_name) do + if is_a? Widget + elem = send(dom_type, *args, &block) + else + elem = @browser.send(dom_type, *args, &block) + end + + if elem.is_a?(Watir::ElementCollection) || elem.is_a?(Watir::HTMLElementCollection) + raise ArgumentError, "Individual widget method :#{method_name} cannot initialize a widget using an element collection (#{elem.class}.) Use :#{method_name.pluralize} rather than :#{method_name} if you want to define a widget collection." + else + subclass.new(self, dom_type, *args, &block) + end + end + end + + WidgetMethods.send(:define_method, pluralized_name_string) do |method_name, dom_type, *args, &block| + if block_given? + subclass.class_eval { block.call } + end + + define_method(method_name) do + if is_a? Widget + elem = send(dom_type, *args, &block) + else + elem = @browser.send(dom_type, *args, &block) + end + + if elem.is_a?(Watir::Element) || elem.is_a?(Watir::HTMLElement) + raise ArgumentError, "Widget collection method :#{method_name} cannot initialize a widget collection using an individual element (#{elem.class}.) Use :#{method_name.singularize} rather than :#{method_name} if you want to define a widget for an individual element." + else + elem.to_a.map! { |x| subclass.new(self, x, [], &block) } + end + end + end + end # self. + + # This method gets used 2 different ways. Most of the time, dom_type and args + # will be a symbol and a set of hash arguments that will be used to locate an + # element. + # + # In some cases, dom_type can be a Watir DOM object, and in this case, the + # args are ignored and the widget is initialized using the Watir object. + # + # TODO: Needs a rewrite, lines between individual and collection are blurred + # here and that makes the code more confusing. And there should be a proper + # collection class for element collections, with possibly some AR-like accessors. + def initialize(parent, dom_type, *args) + @parent = parent + @site = parent.class.ancestors.include?(SiteObject) ? parent : parent.site + @browser = @site.browser + + if dom_type.is_a?(Watir::HTMLElement) || dom_type.is_a?(Watir::Element) + @dom_type = nil + @args = nil + @target = dom_type.to_subtype + elsif [String, Symbol].include? dom_type.class + @dom_type = dom_type + @args = args + + if @parent.is_a? Widget + @target = @parent.send(dom_type, *args) + else + @target = @browser.send(dom_type, *args) + end + elsif dom_type.is_a? Watir::ElementCollection + @dom_type = nil + @args = nil + if @parent.is_a? Widget + @target = dom_type.map { |x| self.class.new(@parent, x.to_subtype) } + else + @target = dom_type.map { |x| self.class.new(@site, x.to_subtype) } + end + else + raise "Unhandled." + end + end + + # Delegates method calls down to the widget's wrapped element if the element supports the method. + # + # Supports dynamic link methods. Examples: + # s.accounts_page account + # + # # Nav to linked page only. + # s.account_actions.edit_account_info + # + # # Update linked page after nav: + # s.account_actions.edit_account_info username: 'foo' + # + # # Link with modal (if the modal requires args they should be passed as hash keys): + # # s.hosted_pages.refresh_urls + # TODO: + # + # - Forms within cards? (/accounts/:account_code account notes section.) + # - Static method for email links. + def method_missing(mth, *args, &block) + if @target.respond_to? mth + return @target.send(mth, *args, &block) + else + if args[0].is_a? Hash + page_arguments = args[0]#.with_indifferent_access + error_check = page_arguments.delete(:error_check) + elsif args[0].nil? + # Do nothing. + else + raise ArgumentError, "Optional argument must be a hash (got #{args[0].class}.)" + end + + if present? + widget_links = as + else + widget_links = [] + end + + if mth.to_s =~ /_link$/ + return a(text: /^#{mth.to_s.sub(/_link$/, '').gsub('_', ' ')}/i) + elsif lnk = widget_links.find { |x| x.text =~ /^#{mth.to_s.gsub('_', ' ')}/i } + lnk.when_present(3) + lnk.click + sleep 1 + + if @site.modal.present? + @site.modal.continue(page_arguments) + else + current_page = @site.page + + if page_arguments.present? + if error_check != false + if tmp = @site.page_errors + raise tmp.to_s + end + end + + if current_page.respond_to?(:submit) + current_page.submit page_arguments + elsif @browser.input(xpath: "//div[starts-with(@class,'Row') and last()]//input[@type='submit' and last()]").present? + current_page.update_page page_arguments + @browser.input(xpath: "//div[starts-with(@class,'Row') and last()]//input[@type='submit' and last()]").click + end + current_page = @site.page + end + end + else + super + end + end + + if error_check != false + if tmp = @site.page_errors + raise tmp.to_s + end + end + + page_arguments.present? ? page_arguments : current_page + end + + def nokogiri + Nokogiri::HTML(html) + end + + def present? + @target.present? + end +end