rutl 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.gitignore +1 -1
  4. data/.rubocop.yml +1 -1
  5. data/.rubocop_todo.yml +37 -10
  6. data/.travis.yml +1 -0
  7. data/README.md +75 -38
  8. data/appveyor.yml +48 -0
  9. data/bin/ie.reg +0 -0
  10. data/bin/window_data.rb +27 -0
  11. data/lib/rspec/default_method_object_to_app.rb +28 -0
  12. data/lib/rspec/rutl_matchers.rb +7 -5
  13. data/lib/rutl.rb +26 -6
  14. data/lib/rutl/appium/appium_extension.rb +27 -0
  15. data/lib/rutl/appium/appium_server.rb +36 -0
  16. data/lib/rutl/appium/windows_test_app_wrapper.rb +40 -0
  17. data/lib/rutl/application.rb +70 -0
  18. data/lib/rutl/camera.rb +20 -4
  19. data/lib/rutl/element/click_to_change_state_mixin.rb +1 -1
  20. data/lib/rutl/element/element.rb +9 -10
  21. data/lib/rutl/element/element_context.rb +5 -4
  22. data/lib/rutl/element/string_reader_writer_mixin.rb +11 -7
  23. data/lib/rutl/interface/base.rb +30 -28
  24. data/lib/rutl/interface/browser/browser.rb +22 -0
  25. data/lib/rutl/interface/{chrome.rb → browser/chrome.rb} +3 -10
  26. data/lib/rutl/interface/{firefox.rb → browser/firefox.rb} +3 -10
  27. data/lib/rutl/interface/browser/internet_explorer.rb +23 -0
  28. data/lib/rutl/interface/browser/null.rb +36 -0
  29. data/lib/rutl/interface/windows/hello.rb +36 -0
  30. data/lib/rutl/interface/windows/notepad.rb +26 -0
  31. data/lib/rutl/interface/windows/windows_app.rb +35 -0
  32. data/lib/rutl/null_driver/null_driver.rb +4 -4
  33. data/lib/rutl/null_driver/null_element.rb +4 -4
  34. data/lib/rutl/version.rb +1 -1
  35. data/lib/rutl/{page.rb → view.rb} +37 -28
  36. data/lib/utilities/check_view.rb +12 -0
  37. data/lib/utilities/string.rb +12 -0
  38. data/lib/utilities/waiter.rb +23 -0
  39. data/rutl.gemspec +13 -0
  40. metadata +94 -10
  41. data/lib/rspec/default_rspec_to_browser.rb +0 -22
  42. data/lib/rutl/browser.rb +0 -70
  43. data/lib/rutl/interface/null.rb +0 -35
  44. data/lib/utilities.rb +0 -41
@@ -1,11 +1,13 @@
1
- require 'utilities'
1
+ require 'utilities/check_view'
2
2
  #
3
3
  # Additional RSpec matchers specific to this framework go here.
4
4
  #
5
+ module RSpec
6
+ include CheckView
5
7
 
6
- # Is it the expected page?
7
- RSpec::Matchers.define :be_page do |expected|
8
- match do |actual|
9
- actual.is_a?(expected) && page?(expected)
8
+ Matchers.define :be_view do |expected|
9
+ match do |actual|
10
+ actual.is_a?(expected) && view?(expected)
11
+ end
10
12
  end
11
13
  end
data/lib/rutl.rb CHANGED
@@ -1,4 +1,4 @@
1
- require 'rutl/browser'
1
+ require 'rutl/application'
2
2
  require 'rutl/version'
3
3
  #
4
4
  # TODO: Rename to something better. RubyUI2API? RAPID for Ruby API DSL?
@@ -6,13 +6,33 @@ require 'rutl/version'
6
6
  # desktop UI testing, turning the UI into an API via its DSL.
7
7
  #
8
8
  module RUTL
9
- # Should define RUTL::PAGES directory for your code
10
- # or set ENV['RUTL_PAGES']
11
- # or Browser intialize will raise.
12
- # PAGES = nil
9
+ # maybe this doesn't exist so much anymore.
10
+ # Should there be one flat directory? Nested dirs organized?
11
+ # web pages aren't the same as app views (have urls)
12
+ # or do I make them look the same?
13
+ # Not have any methods for views that depend on url or have them explicitly
14
+ # call out url and non-url versions.
15
+ #
16
+ # Should define RUTL::VIEWS directory for your code
17
+ # or set ENV['RUTL_VIEWS']
18
+ # or Application intialize will raise.
19
+ # VIEWS = nil
20
+
21
+ # HUNGARIAN automatically appends _<element_type> to all view elements.
22
+ # So
23
+ # button :foo
24
+ # is referred to later in code as
25
+ # foo_button
26
+ # instead of just the flat name. So
27
+ # bar_link.click
28
+ # instead of
29
+ # bar.click
30
+ #
31
+ # And I like it so it goes on by default.
32
+ HUNGARIAN = true
13
33
 
14
34
  # If this RUTL::SCREENSHOT_DIR or ENV['SCREENSHOT_DIR']
15
- # or Browser initialize is set, we take screenshots.
35
+ # or Application initialize is set, we take screenshots.
16
36
  # SCREENSHOTS = nil
17
37
 
18
38
  # This one is for diffing against.
@@ -0,0 +1,27 @@
1
+ module Appium
2
+ ##
3
+ # Extend Appium because I keep digging through WinAppDriver junk to
4
+ # figure out what's broken.
5
+ #
6
+ class Driver
7
+ def start
8
+ start_driver
9
+ rescue RuntimeError => e
10
+ if e.cause.to_s =~ /Failed to open TCP connection/
11
+ puts 'Cannot reach Appium server. Is it running? On the right port?'
12
+ else
13
+ puts "Cannot diagnose:\n#{e}"
14
+ end
15
+ exit
16
+ rescue Selenium::WebDriver::Error::NoSuchWindowError => e
17
+ puts "\n\n" + e.class
18
+ puts "\n\n" + e.cause
19
+ puts "\n\n" + e.backtrace
20
+ puts "\n\n" + (e.methods - Class.methods)
21
+ end
22
+
23
+ def quit
24
+ driver_quit
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ require 'faraday'
2
+ require 'utilities/waiter'
3
+ #
4
+ # Class to wrap Appium in a Rubyish way.
5
+ #
6
+ class AppiumServer
7
+ include Waiter
8
+ attr_accessor :port, :server
9
+
10
+ def initialize(server: nil, port: nil)
11
+ @server = server || 'localhost'
12
+ @port = port || 4723
13
+ end
14
+
15
+ def quiet_cmd(in_string)
16
+ system in_string + ' 1>nul 2>&1'
17
+ end
18
+
19
+ def start
20
+ raise 'server already started' if started?
21
+ quiet_cmd('start "appium" cmd /c appium')
22
+ await -> { started? }
23
+ end
24
+
25
+ def started?
26
+ Faraday.get("http://#{@server}:#{@port}/wd/hub/status")
27
+ true
28
+ rescue Faraday::ConnectionFailed
29
+ false
30
+ end
31
+
32
+ def stop
33
+ raise 'server not started' unless started?
34
+ quiet_cmd('taskkill /f /fi "WINDOWTITLE eq appium" /t')
35
+ end
36
+ end
@@ -0,0 +1,40 @@
1
+ require 'win32/window'
2
+ require 'utilities/waiter'
3
+ #
4
+ # wrapper for simple test apps
5
+ #
6
+ class WindowsTestApp
7
+ include Waiter
8
+ attr_reader :window_handle_string
9
+
10
+ def initialize(name:, title:)
11
+ @name = name
12
+ @title = title
13
+ end
14
+
15
+ def find_window_by_title
16
+ result = Win32::Window.find(title: @title)
17
+ raise 'found more than one instance of app' if result.size > 1
18
+ result.empty? ? false : result.first
19
+ end
20
+
21
+ def wait_for_started
22
+ app_window = await -> { find_window_by_title }
23
+ @pid = app_window.pid
24
+ @window_handle_string = format('0x%08x', app_window.handle)
25
+ end
26
+
27
+ def start
28
+ quiet_cmd "start \"NO TITLE\" #{@name}"
29
+ wait_for_started
30
+ end
31
+
32
+ def stop
33
+ quiet_cmd "taskkill /f /pid #{@pid} /t"
34
+ end
35
+ alias kill stop
36
+
37
+ def quiet_cmd(in_string)
38
+ system in_string + ' 1>nul 2>&1'
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ require 'rutl/view'
2
+ require 'utilities/string'
3
+
4
+ module RUTL
5
+ #
6
+ # Application, this top-level class, controls a browser and
7
+ # a fake browser. It will soon call into apps, at which point I need to
8
+ # rethink this naming convention.
9
+ #
10
+ class Application
11
+ attr_reader :interface
12
+
13
+ def screenshot
14
+ @interface.camera.screenshot
15
+ end
16
+
17
+ def initialize(family:, type:, views: RUTL::VIEWS || ENV['RUTL_VIEWS'])
18
+ raise 'Must set views!' if views.nil? || views.empty?
19
+ # This is kind of evil. Figure out how to ditch the $ variable.
20
+ $application = self
21
+ @interface = load_interface(family: family, type: type)
22
+ @interface.views = load_views(directory: views)
23
+ end
24
+
25
+ def method_missing(method, *args, &block)
26
+ if args.empty?
27
+ @interface.send(method)
28
+ else
29
+ @interface.send(method, *args, &block)
30
+ end
31
+ end
32
+
33
+ def respond_to_missing?(*args)
34
+ @interface.respond_to?(*args)
35
+ end
36
+
37
+ private
38
+
39
+ def load_interface(family:, type:)
40
+ require "rutl/interface/#{family}/#{type}"
41
+ klass = "RUTL::Interface::#{type.to_s.pascal_case}"
42
+ Object.const_get(klass).new
43
+ end
44
+
45
+ def load_views(directory:)
46
+ require_views(directory: directory).map do |klass|
47
+ Object.const_get(klass).new(@interface)
48
+ end
49
+ end
50
+
51
+ # Ugly. Requires files for view objects.
52
+ # Returns array of class names to load.
53
+ def require_views(directory:)
54
+ Dir["#{directory}/*"].map do |file|
55
+ result = find_class_name(file)
56
+ result if result
57
+ end
58
+ end
59
+
60
+ def find_class_name(file)
61
+ require "rutl/../../#{file}"
62
+ File.open(file).each do |line|
63
+ bingo = line.match(/class (.*) < RUTL::View/)
64
+ # One class per file.
65
+ return bingo[1] if bingo && bingo[1]
66
+ end
67
+ false
68
+ end
69
+ end
70
+ end
data/lib/rutl/camera.rb CHANGED
@@ -27,14 +27,30 @@ module RUTL
27
27
 
28
28
  def shoot(path = nil)
29
29
  return if guard
30
+ file = prepare_shot(path)
31
+ cheese_already(file)
32
+ end
33
+ alias screenshot shoot
34
+
35
+ def prepare_shot(path = nil)
36
+ FileUtils.mkdir_p @dir
30
37
  # Magic path is used for all auto-screenshots.
31
38
  name = path || magic_path
39
+ File.join(@dir, pathify(name))
40
+ end
32
41
 
33
- FileUtils.mkdir_p @dir
34
- file = File.join(@dir, pathify(name))
35
- @driver.save_screenshot(file)
42
+ def cheese_already(file)
43
+ if @driver.respond_to?(:save_screenshot)
44
+ @driver.save_screenshot(file)
45
+ elsif @driver.respond_to?(:screenshot)
46
+ @driver.screenshot(file)
47
+ else
48
+ raise 'unknown screenshot method!'
49
+ end
50
+ rescue Selenium::WebDriver::Error::NoSuchWindowError
51
+ puts 'app closed; no photos, please'
52
+ # leave a zero length file as a sign that we came down this path
36
53
  end
37
- alias screenshot shoot
38
54
 
39
55
  def clean_dir(dir)
40
56
  FileUtils.rm_rf dir
@@ -14,7 +14,7 @@ module RUTL
14
14
  # * Returns the state we transitioned to.
15
15
  def click
16
16
  @context.interface.camera.screenshot
17
- this_css.click
17
+ @context.find_element.click
18
18
  result = @context.interface.wait_for_transition(@context.destinations)
19
19
  @context.interface.camera.screenshot
20
20
  result
@@ -1,7 +1,7 @@
1
1
  module RUTL
2
2
  module Element
3
3
  #
4
- # Page element base class.
4
+ # View element base class.
5
5
  #
6
6
  class Element
7
7
  attr_accessor :context
@@ -10,19 +10,18 @@ module RUTL
10
10
  raise element_context.to_s unless element_context.is_a? ElementContext
11
11
  @context = element_context
12
12
  # Not sure why, but I'm seeing Chrome fail becase the context interface
13
- # passed in isn't the same as the browser's interface.
13
+ # passed in isn't the same as the application's interface.
14
14
  # This only happens with click test cases, before the click, and
15
15
  # only if that case isn't run first.
16
16
  # The context we're passed is also an instance from as
17
17
  # RUTL::Interface::Chrome, but a different instance.
18
18
  #
19
19
  # Here's the kludge workaround line:
20
- @context.interface = $browser.interface
20
+ @context.interface = $application.interface
21
21
  end
22
22
 
23
- # Returns the element at this css selector.
24
- def this_css
25
- @context.find_element(:css)
23
+ def find_element
24
+ @context.find_element
26
25
  end
27
26
 
28
27
  # Returns boolean, of course.
@@ -30,21 +29,21 @@ module RUTL
30
29
  # have a valid Element object for something that doesn't exist. Anymore.
31
30
  # Or yet.
32
31
  def exists?
33
- this_css
32
+ find_element
34
33
  rescue Selenium::WebDriver::Error::NoSuchElementError
35
34
  false
36
35
  end
37
36
 
38
37
  def method_missing(method, *args, &block)
39
38
  if args.empty?
40
- this_css.send(method)
39
+ find_element.send(method)
41
40
  else
42
- this_css.send(method, *args, &block)
41
+ find_element.send(method, *args, &block)
43
42
  end
44
43
  end
45
44
 
46
45
  def respond_to_missing?(*args)
47
- this_css.respond_to?(*args)
46
+ find_element.respond_to?(*args)
48
47
  end
49
48
  end
50
49
  end
@@ -17,7 +17,7 @@ module RUTL
17
17
  def initialize(destinations: nil, interface: nil, selectors: [])
18
18
  unless destinations.nil? || destinations.class == Array
19
19
  # Should check each destination to make sure it's a
20
- # Page or a _____, too.
20
+ # View or a _____, too.
21
21
  raise 'destination must be an Array of destinations or nil.'
22
22
  end
23
23
  @destinations = destinations || []
@@ -29,11 +29,12 @@ module RUTL
29
29
  @selectors = selectors
30
30
  end
31
31
 
32
- def find_element(type)
32
+ def find_element(type = nil)
33
+ type ||= @selectors.first.first
33
34
  # @interface.driver.find_element(type, @selectors[type])
34
35
  # Should be this, but apparently @interface.driver is being overwritten
35
- # (or not written to) and it doesn't work. Using $browser does. :-(
36
- $browser.interface.driver.find_element(type, @selectors[type])
36
+ # (or not written to) and it doesn't work. Using $application does. :-(
37
+ $application.interface.driver.find_element(type, @selectors[type])
37
38
  end
38
39
  end
39
40
  end
@@ -14,28 +14,32 @@ module RUTL
14
14
 
15
15
  # I could cut set() and only foo_text= if I change this.
16
16
  # The problem I'm running into is not having the driver in
17
- # base element to do this_css calls. So I have to change the way
17
+ # base element to do find_element calls. So I have to change the way
18
18
  # drivers are passed into everything or initially have them everywhere,
19
- # which means rewriting chosen drivers or changing page load.
19
+ # which means rewriting chosen drivers or changing view load.
20
20
  # Ick.
21
21
  def set(string)
22
22
  clear
23
- this_css.send_keys(string)
23
+ find_element.send_keys(string)
24
24
  end
25
25
  alias text= set
26
26
  alias value= set
27
27
 
28
28
  # Return the String held by this element.
29
29
  def get
30
- this_css.attribute(:value)
30
+ found = find_element
31
+ # This is a clumsy workaround for winappdriver, which gets textfields
32
+ # as #text even though everything else seems to use #attribute(:value).
33
+ # If both are false, this is undefined.
34
+ found.attribute(:value) || found.text
31
35
  end
32
36
  alias text get
33
37
  alias value get
34
38
  alias to_s get
35
39
 
36
- # Talk to the page and set the element's string to ''.
40
+ # Talk to the view and set the element's string to ''.
37
41
  def clear
38
- this_css.clear
42
+ find_element.clear
39
43
  get
40
44
  end
41
45
 
@@ -48,7 +52,7 @@ module RUTL
48
52
  # Sends these keystrokes without clearing the field.
49
53
  # Returns the whole string in the field, including this input.
50
54
  def send_keys(string)
51
- this_css.send_keys(string)
55
+ find_element.send_keys(string)
52
56
  get
53
57
  end
54
58
 
@@ -1,15 +1,17 @@
1
- require 'utilities'
2
1
  require 'rutl/camera'
2
+ require 'utilities/check_view'
3
+ require 'utilities/waiter'
3
4
 
4
5
  module RUTL
5
6
  module Interface
6
7
  #
7
8
  # I might need to consider renaming these.
8
- # The *interface classes lie between Browser
9
+ # The *interface classes lie between Application
9
10
  # and the webdriver-level classes.
10
11
  #
11
12
  class Base
12
- include Utilities
13
+ include CheckView
14
+ include Waiter
13
15
 
14
16
  # RUTL::Driver
15
17
  attr_accessor :driver
@@ -17,72 +19,72 @@ module RUTL
17
19
  # RUTL::Camera
18
20
  attr_accessor :camera
19
21
 
20
- # Array of all RUTL::Page classes
21
- attr_accessor :pages
22
+ # Array of all RUTL::View classes
23
+ attr_accessor :views
22
24
 
23
25
  def initialize
24
26
  raise 'Child interface class must set @driver.' if @driver.nil?
25
27
  # base_name avoids collisions when unning the same tests with
26
- # different browsers.
28
+ # different applications
27
29
  name = self.class.to_s.sub('RUTL::Interface', '')
28
30
  @camera = Camera.new(@driver, base_name: name)
29
31
  end
30
32
 
31
- # Attempts to navigate to the page.
33
+ # Attempts to navigate to the view.
32
34
  # Takes screenshot if successful.
33
- def goto(page)
34
- raise 'expect Page class' unless page?(page)
35
- find_page(page).go_to_here
35
+ def goto(view)
36
+ raise 'expect View class' unless view?(view)
37
+ find_view(view).go_to_here
36
38
  @camera.screenshot
37
39
  end
38
40
 
39
41
  # Should define in children; raises here.
40
- # Should return the current page class.
41
- def current_page
42
+ # Should return the current view class.
43
+ def current_view
42
44
  raise 'define in child classes'
43
45
  end
44
46
 
45
47
  def method_missing(method, *args, &block)
46
48
  if args.empty?
47
- current_page.send(method)
49
+ current_view.send(method)
48
50
  else
49
- current_page.send(method, *args, &block)
51
+ current_view.send(method, *args, &block)
50
52
  end
51
53
  end
52
54
 
53
- # TODO: Is this needed? I not only find the page but also make sure the
54
- # urls match. Even though that's what finding pages means?
55
+ # TODO: Is this needed? I not only find the view but also make sure the
56
+ # urls match. Even though that's what finding views means?
55
57
  def find_state(target_states)
56
58
  target_states.each do |state|
57
- next unless state.url == current_page.url
58
- page = find_page(state)
59
- return page if page.loaded?
59
+ next unless state.url == current_view.url
60
+ view = find_view(state)
61
+ return view if view.loaded?
60
62
  end
61
63
  false
62
64
  end
63
65
 
64
- # Attempts to find page by class or url.
65
- def find_page(page)
66
- @pages.each do |p|
67
- return p if page?(page) && p.class == page
68
- return p if String == page.class && page == p.url
66
+ # Attempts to find view by class or url.
67
+ def find_view(view)
68
+ @views.each do |p|
69
+ return p if view?(view) && p.class == view
70
+ return p if String == view.class && view == p.url
69
71
  end
70
- raise "Page \"#{page}\" not found in pages #{@pages}"
72
+ raise "View \"#{view}\" not found in views #{@views}"
71
73
  end
72
74
 
73
75
  # Calls the polling utility mathod await() with a lambda trying to
74
- # find the next state, probably a Page class.
76
+ # find the next state, probably a View class.
75
77
  def wait_for_transition(target_states)
76
78
  #
77
79
  # TODO: Should also see if there are other things to wait for.
78
- # I don't think this is doing page load time.
80
+ # I don't think this is doing view load time.
79
81
  #
80
82
  await -> { find_state target_states }
81
83
  end
82
84
 
83
85
  def respond_to_missing?(*args)
84
86
  # This can't be right. Figure it out later.
85
- current_page.respond_to?(*args)
87
+ current_view.respond_to?(*args)
86
88
  end
87
89
 
88
90
  def quit