rutl 0.6.0 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.gitignore +1 -1
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +37 -10
- data/.travis.yml +1 -0
- data/README.md +75 -38
- data/appveyor.yml +48 -0
- data/bin/ie.reg +0 -0
- data/bin/window_data.rb +27 -0
- data/lib/rspec/default_method_object_to_app.rb +28 -0
- data/lib/rspec/rutl_matchers.rb +7 -5
- data/lib/rutl.rb +26 -6
- data/lib/rutl/appium/appium_extension.rb +27 -0
- data/lib/rutl/appium/appium_server.rb +36 -0
- data/lib/rutl/appium/windows_test_app_wrapper.rb +40 -0
- data/lib/rutl/application.rb +70 -0
- data/lib/rutl/camera.rb +20 -4
- data/lib/rutl/element/click_to_change_state_mixin.rb +1 -1
- data/lib/rutl/element/element.rb +9 -10
- data/lib/rutl/element/element_context.rb +5 -4
- data/lib/rutl/element/string_reader_writer_mixin.rb +11 -7
- data/lib/rutl/interface/base.rb +30 -28
- data/lib/rutl/interface/browser/browser.rb +22 -0
- data/lib/rutl/interface/{chrome.rb → browser/chrome.rb} +3 -10
- data/lib/rutl/interface/{firefox.rb → browser/firefox.rb} +3 -10
- data/lib/rutl/interface/browser/internet_explorer.rb +23 -0
- data/lib/rutl/interface/browser/null.rb +36 -0
- data/lib/rutl/interface/windows/hello.rb +36 -0
- data/lib/rutl/interface/windows/notepad.rb +26 -0
- data/lib/rutl/interface/windows/windows_app.rb +35 -0
- data/lib/rutl/null_driver/null_driver.rb +4 -4
- data/lib/rutl/null_driver/null_element.rb +4 -4
- data/lib/rutl/version.rb +1 -1
- data/lib/rutl/{page.rb → view.rb} +37 -28
- data/lib/utilities/check_view.rb +12 -0
- data/lib/utilities/string.rb +12 -0
- data/lib/utilities/waiter.rb +23 -0
- data/rutl.gemspec +13 -0
- metadata +94 -10
- data/lib/rspec/default_rspec_to_browser.rb +0 -22
- data/lib/rutl/browser.rb +0 -70
- data/lib/rutl/interface/null.rb +0 -35
- data/lib/utilities.rb +0 -41
data/lib/rspec/rutl_matchers.rb
CHANGED
@@ -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
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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/
|
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
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
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
|
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
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
17
|
+
@context.find_element.click
|
18
18
|
result = @context.interface.wait_for_transition(@context.destinations)
|
19
19
|
@context.interface.camera.screenshot
|
20
20
|
result
|
data/lib/rutl/element/element.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
module RUTL
|
2
2
|
module Element
|
3
3
|
#
|
4
|
-
#
|
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
|
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 = $
|
20
|
+
@context.interface = $application.interface
|
21
21
|
end
|
22
22
|
|
23
|
-
|
24
|
-
|
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
|
-
|
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
|
-
|
39
|
+
find_element.send(method)
|
41
40
|
else
|
42
|
-
|
41
|
+
find_element.send(method, *args, &block)
|
43
42
|
end
|
44
43
|
end
|
45
44
|
|
46
45
|
def respond_to_missing?(*args)
|
47
|
-
|
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
|
-
#
|
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 $
|
36
|
-
$
|
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
|
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
|
19
|
+
# which means rewriting chosen drivers or changing view load.
|
20
20
|
# Ick.
|
21
21
|
def set(string)
|
22
22
|
clear
|
23
|
-
|
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
|
-
|
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
|
40
|
+
# Talk to the view and set the element's string to ''.
|
37
41
|
def clear
|
38
|
-
|
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
|
-
|
55
|
+
find_element.send_keys(string)
|
52
56
|
get
|
53
57
|
end
|
54
58
|
|
data/lib/rutl/interface/base.rb
CHANGED
@@ -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
|
9
|
+
# The *interface classes lie between Application
|
9
10
|
# and the webdriver-level classes.
|
10
11
|
#
|
11
12
|
class Base
|
12
|
-
include
|
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::
|
21
|
-
attr_accessor :
|
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
|
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
|
33
|
+
# Attempts to navigate to the view.
|
32
34
|
# Takes screenshot if successful.
|
33
|
-
def goto(
|
34
|
-
raise 'expect
|
35
|
-
|
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
|
41
|
-
def
|
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
|
-
|
49
|
+
current_view.send(method)
|
48
50
|
else
|
49
|
-
|
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
|
54
|
-
# urls match. Even though that's what finding
|
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 ==
|
58
|
-
|
59
|
-
return
|
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
|
65
|
-
def
|
66
|
-
@
|
67
|
-
return p if
|
68
|
-
return p if String ==
|
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 "
|
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
|
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
|
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
|
-
|
87
|
+
current_view.respond_to?(*args)
|
86
88
|
end
|
87
89
|
|
88
90
|
def quit
|