ruby_raider 3.11.0 → 3.12.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.
- checksums.yaml +4 -4
- data/.github/workflows/reek.yml +5 -1
- data/.github/workflows/release.yml +2 -1
- data/.github/workflows/system_tests.yml +1 -1
- data/.reek.yml +13 -0
- data/README.md +43 -4
- data/lib/commands/utility_commands.rb +1 -1
- data/lib/generators/automation/automation_generator.rb +15 -10
- data/lib/generators/automation/templates/appium_caps.tt +2 -4
- data/lib/generators/automation/templates/home.tt +1 -2
- data/lib/generators/automation/templates/login.tt +6 -6
- data/lib/generators/automation/templates/page.tt +10 -3
- data/lib/generators/automation/templates/partials/home_page_selector.tt +4 -6
- data/lib/generators/automation/templates/partials/pdp_page_selector.tt +4 -6
- data/lib/generators/automation/templates/pdp.tt +3 -2
- data/lib/generators/cucumber/cucumber_generator.rb +15 -2
- data/lib/generators/cucumber/templates/partials/driver_world.tt +9 -0
- data/lib/generators/cucumber/templates/partials/mobile_steps.tt +1 -1
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +4 -0
- data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
- data/lib/generators/generator.rb +15 -4
- data/lib/generators/helper_generator.rb +8 -0
- data/lib/generators/invoke_generators.rb +23 -0
- data/lib/generators/menu_generator.rb +26 -14
- data/lib/generators/rspec/rspec_generator.rb +10 -3
- data/lib/generators/rspec/templates/spec.tt +15 -1
- data/lib/generators/template_renderer/template_error.rb +8 -8
- data/lib/generators/templates/common/partials/mobile_config.tt +1 -0
- data/lib/generators/templates/common/partials/web_config.tt +1 -1
- data/lib/generators/templates/common/read_me.tt +508 -24
- data/lib/generators/templates/helpers/partials/selenium_driver.tt +1 -3
- data/lib/generators/templates/helpers/raider_element.tt +159 -0
- data/lib/generators/templates/helpers/session_state.tt +45 -5
- data/lib/generators/templates/helpers/spec_helper.tt +9 -1
- data/lib/generators/templates/helpers/wait_helper.tt +470 -0
- data/lib/ruby_raider/ffmpeg.rb +7 -7
- data/lib/ruby_raider.rb +2 -0
- data/lib/scaffolding/project_detector.rb +6 -0
- data/lib/scaffolding/scaffolding.rb +4 -0
- data/lib/scaffolding/templates/component.tt +4 -0
- data/lib/scaffolding/templates/page_object.tt +8 -0
- data/lib/version +1 -1
- data/sig/generators/generator.rbs +2 -0
- data/sig/scaffolding/project_detector.rbs +1 -0
- data/sig/scaffolding/scaffolding.rbs +1 -0
- data/spec/commands/raider_commands_spec.rb +24 -0
- data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +175 -0
- data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +24 -0
- data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +39 -0
- data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +33 -0
- data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +174 -0
- data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +39 -0
- data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +33 -0
- data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +49 -0
- data/spec/integration/commands/scaffolding_commands_spec.rb +49 -0
- data/spec/integration/commands/utility_commands_spec.rb +8 -0
- data/spec/integration/content/config_content_spec.rb +44 -0
- data/spec/integration/content/helper_content_spec.rb +79 -0
- data/spec/integration/content/page_content_spec.rb +75 -4
- data/spec/integration/content/readme_content_spec.rb +126 -5
- data/spec/integration/content/syntax_validation_spec.rb +23 -0
- data/spec/integration/content/test_content_spec.rb +57 -0
- data/spec/integration/end_to_end_spec.rb +50 -28
- data/spec/integration/generators/automation_generator_spec.rb +50 -0
- data/spec/integration/generators/common_generator_spec.rb +20 -0
- data/spec/integration/generators/config_features_spec.rb +2 -2
- data/spec/integration/generators/cucumber_generator_spec.rb +36 -0
- data/spec/integration/generators/helpers_generator_spec.rb +38 -0
- data/spec/integration/generators/invoke_generators_spec.rb +70 -0
- data/spec/integration/generators/rspec_generator_spec.rb +20 -0
- data/spec/integration/helpers/session_state_spec.rb +46 -0
- data/spec/integration/helpers/wait_helper_mobile_spec.rb +281 -0
- data/spec/integration/helpers/wait_helper_spec.rb +496 -0
- data/spec/integration/helpers/wait_helper_support.rb +183 -0
- data/spec/integration/settings_helper.rb +4 -1
- data/spec/integration/spec_helper.rb +2 -2
- data/spec/menus/menu_generator_spec.rb +64 -2
- data/spec/scaffolding/scaffold_project_detector_spec.rb +12 -0
- metadata +15 -2
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'selenium-webdriver'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'raider_element'
|
|
6
|
+
|
|
7
|
+
<%- if mobile? -%>
|
|
8
|
+
# Waits before it acts. Each method takes a locator ({ accessibility_id: 'name' },
|
|
9
|
+
# with or without braces, or { ios: {...}, android: {...} }), a RaiderElement or a
|
|
10
|
+
# raw element, polls every 0.1s until the element is ready, then acts on it:
|
|
11
|
+
# tap({ accessibility_id: 'login' })
|
|
12
|
+
# send_keys({ accessibility_id: 'user' }, 'admin')
|
|
13
|
+
# swipe('left', { accessibility_id: 'card' })
|
|
14
|
+
# scroll_to({ accessibility_id: 'footer' })
|
|
15
|
+
# eventually { expect(text(accessibility_id: 'welcome')).to eq('Hi') }
|
|
16
|
+
<%- else -%>
|
|
17
|
+
# Waits before it acts. Each method takes a locator ({ id: 'name' }, with or
|
|
18
|
+
# without braces), a RaiderElement or a raw element, polls every 0.1s until the
|
|
19
|
+
# element is ready, then calls the Selenium method of the same name:
|
|
20
|
+
# click({ id: 'login' })
|
|
21
|
+
# send_keys({ id: 'user' }, 'admin')
|
|
22
|
+
# eventually { expect(text(css: '.welcome')).to eq('Hi') }
|
|
23
|
+
<%- end -%>
|
|
24
|
+
# The wait is config/config.yml `timeout` seconds, or `timeout:` for one call.
|
|
25
|
+
module WaitHelper
|
|
26
|
+
class TimeoutError < Selenium::WebDriver::Error::TimeoutError; end
|
|
27
|
+
|
|
28
|
+
CONFIG_FILE = 'config/config.yml'
|
|
29
|
+
DEFAULT_TIMEOUT = 10
|
|
30
|
+
|
|
31
|
+
def self.default_timeout
|
|
32
|
+
@default_timeout ||= begin
|
|
33
|
+
config = File.exist?(CONFIG_FILE) ? YAML.load_file(CONFIG_FILE) : nil
|
|
34
|
+
(config.is_a?(Hash) && config['timeout']) || DEFAULT_TIMEOUT
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.element(driver, locator, by = {})
|
|
39
|
+
RaiderElement.new(driver, Lookup.for(driver, locator, by))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
<%- if mobile? -%>
|
|
43
|
+
# Swipes or scrolls the element, or the whole screen without a locator.
|
|
44
|
+
def self.gesture(driver, kind, direction, locator, timeout)
|
|
45
|
+
return element(driver, locator).public_send(kind, direction, timeout: timeout) if locator
|
|
46
|
+
|
|
47
|
+
gesture = Gesture.new(driver, kind, direction)
|
|
48
|
+
Waiter.new(driver, timeout: timeout).perform_action(kind, Lookup.screen(driver), Waiter::PRESENT) do
|
|
49
|
+
gesture.perform(nil)
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
<%- end -%>
|
|
55
|
+
def find_element(locator = nil, visible: true, timeout: nil, **by)
|
|
56
|
+
Waiter.new(driver, timeout: timeout).find_element(Lookup.for(driver, locator, by), visible)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find_elements(locator = nil, timeout: nil, **by)
|
|
60
|
+
Waiter.new(driver, timeout: timeout).find_elements(Lookup.for(driver, locator, by))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
<%- if mobile? -%>
|
|
64
|
+
%i[click clear text displayed? enabled? selected?].each do |action|
|
|
65
|
+
<%- else -%>
|
|
66
|
+
%i[click clear submit hover scroll_into_view text displayed? enabled? selected?].each do |action|
|
|
67
|
+
<%- end -%>
|
|
68
|
+
define_method(action) do |locator = nil, timeout: nil, **by|
|
|
69
|
+
WaitHelper.element(driver, locator, by).public_send(action, timeout: timeout)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
<%- if mobile? -%>
|
|
74
|
+
# tap is Appium's word for click and waits the same way. With a block and no
|
|
75
|
+
# locator it stays Ruby's Object#tap.
|
|
76
|
+
def tap(locator = nil, timeout: nil, **by, &block)
|
|
77
|
+
return super(&block) if block && locator.nil? && by.empty?
|
|
78
|
+
|
|
79
|
+
WaitHelper.element(driver, locator, by).tap(timeout: timeout)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
<%- end -%>
|
|
83
|
+
def send_keys(locator, *keys, timeout: nil)
|
|
84
|
+
WaitHelper.element(driver, locator).send_keys(*keys, timeout: timeout)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
<%- unless mobile? -%>
|
|
88
|
+
def select_by(locator, how, what, timeout: nil)
|
|
89
|
+
WaitHelper.element(driver, locator).select_by(how, what, timeout: timeout)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
<%- end -%>
|
|
93
|
+
def attribute(locator, name, timeout: nil)
|
|
94
|
+
WaitHelper.element(driver, locator).attribute(name, timeout: timeout)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
<%- if mobile? -%>
|
|
98
|
+
# direction: 'up', 'down', 'left' or 'right'.
|
|
99
|
+
def swipe(direction, locator = nil, timeout: nil)
|
|
100
|
+
WaitHelper.gesture(driver, :swipe, direction, locator, timeout)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def scroll(direction, locator = nil, timeout: nil)
|
|
104
|
+
WaitHelper.gesture(driver, :scroll, direction, locator, timeout)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Scrolls the screen until the element is visible, then returns its RaiderElement.
|
|
108
|
+
def scroll_to(locator, direction: 'down', max_scrolls: 10, timeout: nil)
|
|
109
|
+
WaitHelper.element(driver, locator).scroll_to(direction: direction, max_scrolls: max_scrolls, timeout: timeout)
|
|
110
|
+
end
|
|
111
|
+
<%- else -%>
|
|
112
|
+
def drag_and_drop(source, target, timeout: nil)
|
|
113
|
+
WaitHelper.element(driver, source).drag_and_drop(target, timeout: timeout)
|
|
114
|
+
end
|
|
115
|
+
<%- end -%>
|
|
116
|
+
|
|
117
|
+
def eventually(timeout: nil, &block)
|
|
118
|
+
Waiter.new(nil, timeout: timeout).eventually(&block)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# How an element is found: a locator in a scope (the driver or a parent
|
|
122
|
+
# Lookup), or an element given as is. The found element is cached until reset.
|
|
123
|
+
class Lookup
|
|
124
|
+
OS_KEYS = %i[android ios].freeze
|
|
125
|
+
PASSED_HINT = 'it was passed in as an element, so it cannot be found again; ' \
|
|
126
|
+
"pass its locator (for example { id: 'name' }) instead"
|
|
127
|
+
MEMBER_HINT = 'it came from find_elements, which keeps no locator per element; call find_elements again to re-query'
|
|
128
|
+
|
|
129
|
+
attr_reader :hint
|
|
130
|
+
|
|
131
|
+
def self.for(driver, locator, by, scope: driver)
|
|
132
|
+
raise ArgumentError, 'pass the locator in braces or as keywords, not both' if locator && !by.empty?
|
|
133
|
+
|
|
134
|
+
given = locator || by
|
|
135
|
+
# A wrapper keeps how it was found, so it can still re-find itself.
|
|
136
|
+
return given.__send__(:lookup) if given.is_a?(RaiderElement)
|
|
137
|
+
return new(scope, element: given, hint: PASSED_HINT) if !given.is_a?(Hash) && given.respond_to?(:displayed?)
|
|
138
|
+
|
|
139
|
+
new(scope, locator: normalize(driver, given))
|
|
140
|
+
end
|
|
141
|
+
<%- if mobile? -%>
|
|
142
|
+
|
|
143
|
+
# No locator and no element: a whole-screen gesture, which locates nothing.
|
|
144
|
+
def self.screen(driver)
|
|
145
|
+
new(driver)
|
|
146
|
+
end
|
|
147
|
+
<%- end -%>
|
|
148
|
+
|
|
149
|
+
def self.normalize(driver, given)
|
|
150
|
+
locator = symbolize(given)
|
|
151
|
+
per_os = !locator.empty? && (locator.keys - OS_KEYS).empty?
|
|
152
|
+
locator = symbolize(locator.fetch(driver.appium_device.to_s.downcase.to_sym, nil), given) if per_os
|
|
153
|
+
return locator if locator.size == 1
|
|
154
|
+
|
|
155
|
+
raise ArgumentError, "a locator is a Hash with one key, such as { id: 'name' }; got #{given.inspect}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.symbolize(hash, given = hash)
|
|
159
|
+
return hash.transform_keys(&:to_sym) if hash.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
raise ArgumentError, "a locator is a Hash with one key, such as { id: 'name' }; got #{given.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def initialize(scope, locator: nil, element: nil, hint: nil, many: false)
|
|
165
|
+
@scope = scope
|
|
166
|
+
@locator = locator
|
|
167
|
+
@element = element
|
|
168
|
+
@last_found = element
|
|
169
|
+
@hint = hint
|
|
170
|
+
@many = many
|
|
171
|
+
@scope_stale = false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The last element found. A reset only drops the re-find cache, so a wrapper
|
|
175
|
+
# keeps its element (and its == and hash) after a timed-out wait.
|
|
176
|
+
def element
|
|
177
|
+
@element || @last_found
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def many
|
|
181
|
+
Lookup.new(@scope, locator: @locator, element: @element, many: true)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def locate
|
|
185
|
+
return find_all if @many
|
|
186
|
+
|
|
187
|
+
return @element if @element || !@locator
|
|
188
|
+
|
|
189
|
+
@element = @last_found = in_scope { |context| context.find_element(**@locator) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A stale element may mean its parent was re-rendered too, so stale resets the chain.
|
|
193
|
+
def reset(stale: false)
|
|
194
|
+
@element = nil if @locator
|
|
195
|
+
@scope_stale = false
|
|
196
|
+
@scope.reset(stale: true) if stale && @scope.is_a?(Lookup)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# After a stale error: the Lookup up the chain whose element went stale and cannot be
|
|
200
|
+
# found again (passed in, or from find_elements), or nil when re-finding can heal it.
|
|
201
|
+
def lost
|
|
202
|
+
return self unless @locator
|
|
203
|
+
|
|
204
|
+
@scope.lost if @scope_stale
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def describe
|
|
208
|
+
return 'the element' unless @locator
|
|
209
|
+
|
|
210
|
+
"{#{@locator.map { |key, value| "#{key}: #{value.inspect}" }.join(', ')}}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
# A stale error while searching inside the parent means the parent, or one above it, went stale.
|
|
216
|
+
def in_scope
|
|
217
|
+
return yield(@scope) unless @scope.is_a?(Lookup)
|
|
218
|
+
|
|
219
|
+
begin
|
|
220
|
+
yield(@scope.locate)
|
|
221
|
+
rescue Selenium::WebDriver::Error::StaleElementReferenceError
|
|
222
|
+
@scope_stale = true
|
|
223
|
+
raise
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def find_all
|
|
228
|
+
return [@element] unless @locator
|
|
229
|
+
|
|
230
|
+
found = in_scope { |context| context.find_elements(**@locator) }
|
|
231
|
+
raise Selenium::WebDriver::Error::NoSuchElementError, 'no match' if found.empty?
|
|
232
|
+
|
|
233
|
+
found
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Polls until a Lookup is ready, then yields the raw element. perform_action
|
|
238
|
+
# is the one choke point every public method (bar eventually) goes through.
|
|
239
|
+
class Waiter
|
|
240
|
+
POLL_INTERVAL = 0.1
|
|
241
|
+
DEADLINE_KEY = :raider_wait_deadline
|
|
242
|
+
VISIBLE_ENABLED = %i[visible enabled].freeze
|
|
243
|
+
PRESENT = [].freeze
|
|
244
|
+
<%- if mobile? -%>
|
|
245
|
+
CLICK = %i[visible enabled stable].freeze
|
|
246
|
+
VISIBLE = %i[visible].freeze
|
|
247
|
+
CHECKS = {
|
|
248
|
+
click: CLICK, tap: CLICK,
|
|
249
|
+
send_keys: VISIBLE_ENABLED, clear: VISIBLE_ENABLED,
|
|
250
|
+
swipe: VISIBLE, scroll: VISIBLE, scroll_to: VISIBLE,
|
|
251
|
+
text: PRESENT, attribute: PRESENT, displayed?: PRESENT, enabled?: PRESENT, selected?: PRESENT
|
|
252
|
+
}.freeze
|
|
253
|
+
<%- else -%>
|
|
254
|
+
CHECKS = {
|
|
255
|
+
click: %i[visible enabled stable].freeze,
|
|
256
|
+
send_keys: VISIBLE_ENABLED, clear: VISIBLE_ENABLED, submit: VISIBLE_ENABLED, select_by: VISIBLE_ENABLED,
|
|
257
|
+
hover: %i[visible stable].freeze,
|
|
258
|
+
drag_and_drop: %i[visible enabled stable].freeze,
|
|
259
|
+
scroll_into_view: PRESENT, text: PRESENT, attribute: PRESENT,
|
|
260
|
+
displayed?: PRESENT, enabled?: PRESENT, selected?: PRESENT
|
|
261
|
+
}.freeze
|
|
262
|
+
<%- end -%>
|
|
263
|
+
TARGET_CHECKS = %i[visible].freeze
|
|
264
|
+
STALE = 'stale element'
|
|
265
|
+
RETRIED = [Selenium::WebDriver::Error::StaleElementReferenceError,
|
|
266
|
+
Selenium::WebDriver::Error::ElementClickInterceptedError,
|
|
267
|
+
Selenium::WebDriver::Error::ElementNotInteractableError].freeze
|
|
268
|
+
HELPER_FILES = [__FILE__, File.expand_path('raider_element.rb', __dir__)].freeze
|
|
269
|
+
|
|
270
|
+
def initialize(driver, timeout: nil)
|
|
271
|
+
@driver = driver
|
|
272
|
+
@timeout = timeout
|
|
273
|
+
@last_rect = nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def find_element(lookup, visible)
|
|
277
|
+
perform_action(:find_element, lookup, visible ? TARGET_CHECKS : PRESENT) { RaiderElement.new(@driver, lookup) }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def find_elements(lookup)
|
|
281
|
+
perform_action(:find_elements, lookup.many, PRESENT) do |elements|
|
|
282
|
+
elements.map { |found| RaiderElement.new(@driver, Lookup.new(@driver, element: found, hint: Lookup::MEMBER_HINT)) }
|
|
283
|
+
end
|
|
284
|
+
rescue TimeoutError
|
|
285
|
+
[]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# checks: a subset of %i[visible enabled stable]; present is always checked.
|
|
289
|
+
# target: a second Lookup (the drop target) that must be visible in the same poll.
|
|
290
|
+
# between: called after each poll that was not ready (scroll_to scrolls there); once it
|
|
291
|
+
# returns false the wait stops, and the timeout message ends with its to_s.
|
|
292
|
+
def perform_action(action, locator, checks, target: nil, between: nil)
|
|
293
|
+
lookup = locator.is_a?(Lookup) ? locator : Lookup.for(@driver, locator, {})
|
|
294
|
+
deadline, waited = deadline_and_wait
|
|
295
|
+
loop do
|
|
296
|
+
seen = catch(:not_ready) do
|
|
297
|
+
elements = [ready(lookup, checks)]
|
|
298
|
+
elements << ready(target, TARGET_CHECKS, 'drop target ') if target
|
|
299
|
+
return yield(*elements)
|
|
300
|
+
rescue *RETRIED => e
|
|
301
|
+
retry_reason(lookup, e, action)
|
|
302
|
+
end
|
|
303
|
+
lookup.reset(stale: seen == STALE)
|
|
304
|
+
target&.reset
|
|
305
|
+
if now >= deadline || (between && !between.call)
|
|
306
|
+
seen = [seen, between].compact.join(' ')
|
|
307
|
+
raise TimeoutError, timeout_message(lookup, checks, seen, [waited, waited + now - deadline].min), backtrace
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
pause(deadline)
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Retries the block until it passes. Calls inside it share its deadline (see deadline_and_wait).
|
|
315
|
+
def eventually
|
|
316
|
+
enclosing = Thread.current[DEADLINE_KEY]
|
|
317
|
+
require 'rspec/expectations'
|
|
318
|
+
retried = [RSpec::Expectations::ExpectationNotMetError, Selenium::WebDriver::Error::NoSuchElementError,
|
|
319
|
+
Selenium::WebDriver::Error::StaleElementReferenceError, TimeoutError]
|
|
320
|
+
deadline, waited = deadline_and_wait
|
|
321
|
+
Thread.current[DEADLINE_KEY] = deadline
|
|
322
|
+
loop do
|
|
323
|
+
return yield
|
|
324
|
+
rescue *retried => e
|
|
325
|
+
raise e.exception("(still failing after #{seconds(waited)}s)\n#{e.message}") if now >= deadline
|
|
326
|
+
|
|
327
|
+
pause(deadline)
|
|
328
|
+
end
|
|
329
|
+
ensure
|
|
330
|
+
Thread.current[DEADLINE_KEY] = enclosing
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
private
|
|
334
|
+
|
|
335
|
+
# Inside eventually, a call without timeout: waits for what is left of eventually's
|
|
336
|
+
# deadline; one with timeout: waits that long per attempt, never past eventually's deadline.
|
|
337
|
+
def deadline_and_wait
|
|
338
|
+
started = now
|
|
339
|
+
enclosing = Thread.current[DEADLINE_KEY]
|
|
340
|
+
own = started + (@timeout || WaitHelper.default_timeout) if @timeout || !enclosing
|
|
341
|
+
deadline = [own, enclosing].compact.min
|
|
342
|
+
[deadline, deadline - started]
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def ready(lookup, checks, prefix = '')
|
|
346
|
+
element = lookup.locate
|
|
347
|
+
reason = unready(element, checks)
|
|
348
|
+
throw :not_ready, "#{prefix}#{reason}" if reason
|
|
349
|
+
|
|
350
|
+
element
|
|
351
|
+
rescue Selenium::WebDriver::Error::NoSuchElementError
|
|
352
|
+
throw :not_ready, "#{prefix}not found"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def unready(element, checks)
|
|
356
|
+
return 'present but not visible' if checks.include?(:visible) && !element.displayed?
|
|
357
|
+
return 'visible but disabled' if checks.include?(:enabled) && !element.enabled?
|
|
358
|
+
|
|
359
|
+
'still moving' if checks.include?(:stable) && !stable?(element)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Stable: the same rect on two consecutive polls.
|
|
363
|
+
def stable?(element)
|
|
364
|
+
rect = element.rect
|
|
365
|
+
steady = rect == @last_rect
|
|
366
|
+
@last_rect = rect
|
|
367
|
+
steady
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def retry_reason(lookup, error, action)
|
|
371
|
+
detail = error.message.lines.first.to_s.strip
|
|
372
|
+
case error
|
|
373
|
+
when Selenium::WebDriver::Error::StaleElementReferenceError
|
|
374
|
+
lost = lookup.lost
|
|
375
|
+
raise error.class, "#{detail} (during #{action}): #{lost.hint}", backtrace if lost
|
|
376
|
+
|
|
377
|
+
STALE
|
|
378
|
+
when Selenium::WebDriver::Error::ElementClickInterceptedError then "click intercepted: #{detail}"
|
|
379
|
+
else "not interactable: #{detail}"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def timeout_message(lookup, checks, seen, waited)
|
|
384
|
+
names = checks.map(&:to_s)
|
|
385
|
+
wanted = names.empty? ? 'present' : [names[0..-2].join(', '), names.last].reject(&:empty?).join(' and ')
|
|
386
|
+
"Waited #{seconds(waited)}s for #{lookup.describe} to be #{wanted}; last seen: #{seen}"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def seconds(value)
|
|
390
|
+
rounded = value.round(2)
|
|
391
|
+
rounded == rounded.to_i ? rounded.to_i : rounded
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Drops this helper's frames so the error points at the page object or spec line.
|
|
395
|
+
def backtrace
|
|
396
|
+
caller.reject { |line| HELPER_FILES.any? { |file| line.start_with?(file) } }
|
|
397
|
+
.drop_while { |line| line.start_with?('<internal:') }
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def pause(deadline)
|
|
401
|
+
sleep([[POLL_INTERVAL, deadline - now].min, 0].max)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def now
|
|
405
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
<%- if mobile? -%>
|
|
409
|
+
|
|
410
|
+
# A swipe or scroll through the platform's own mobile: command, as appium_lib issues them.
|
|
411
|
+
class Gesture
|
|
412
|
+
DIRECTIONS = %w[up down left right].freeze
|
|
413
|
+
COMMANDS = {
|
|
414
|
+
ios: { swipe: 'mobile: swipe', scroll: 'mobile: scroll' }.freeze,
|
|
415
|
+
android: { swipe: 'mobile: swipeGesture', scroll: 'mobile: scrollGesture' }.freeze
|
|
416
|
+
}.freeze
|
|
417
|
+
PERCENT = 0.75
|
|
418
|
+
|
|
419
|
+
def initialize(driver, kind, direction)
|
|
420
|
+
@driver = driver
|
|
421
|
+
@device = driver.appium_device.to_s.downcase.to_sym
|
|
422
|
+
@command = COMMANDS.dig(@device, kind)
|
|
423
|
+
raise NotImplementedError, "#{kind} has no gesture for #{driver.appium_device.inspect}; only :ios and :android" unless @command
|
|
424
|
+
|
|
425
|
+
@direction = direction.to_s
|
|
426
|
+
raise ArgumentError, "direction must be up, down, left or right; got #{direction.inspect}" unless DIRECTIONS.include?(@direction)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# element: the raw element to act on, or nil for the whole screen.
|
|
430
|
+
def perform(element)
|
|
431
|
+
@driver.execute_script(@command, arguments(element))
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private
|
|
435
|
+
|
|
436
|
+
def arguments(element)
|
|
437
|
+
return { direction: @direction, element: element&.id }.compact if @device == :ios
|
|
438
|
+
|
|
439
|
+
area = element ? { elementId: element.id } : screen
|
|
440
|
+
area.merge(direction: @direction, percent: PERCENT)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def screen
|
|
444
|
+
rect = @driver.window_rect
|
|
445
|
+
{ left: rect.x, top: rect.y, width: rect.width, height: rect.height }
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Scrolls the screen once per call, between scroll_to's polls, up to max_scrolls times.
|
|
450
|
+
class Scroller
|
|
451
|
+
def initialize(gesture, max_scrolls)
|
|
452
|
+
@gesture = gesture
|
|
453
|
+
@max_scrolls = max_scrolls
|
|
454
|
+
@scrolls = 0
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# False once every scroll is used up.
|
|
458
|
+
def call
|
|
459
|
+
return false if @scrolls >= @max_scrolls
|
|
460
|
+
|
|
461
|
+
@gesture.perform(nil)
|
|
462
|
+
@scrolls += 1
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def to_s
|
|
466
|
+
"after #{@scrolls} scrolls"
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
<%- end -%>
|
|
470
|
+
end
|
data/lib/ruby_raider/ffmpeg.rb
CHANGED
|
@@ -192,18 +192,18 @@ module RubyRaider
|
|
|
192
192
|
[nil, true]
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
-
def verified_cached?(path,
|
|
196
|
-
key = verification_key(path,
|
|
195
|
+
def verified_cached?(path, digest)
|
|
196
|
+
key = verification_key(path, digest)
|
|
197
197
|
return true if @verified[key]
|
|
198
198
|
|
|
199
|
-
ok = verified?(path,
|
|
199
|
+
ok = verified?(path, digest)
|
|
200
200
|
@verified[key] = true if ok
|
|
201
201
|
ok
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
-
def verification_key(path,
|
|
204
|
+
def verification_key(path, digest)
|
|
205
205
|
stat = File.stat(path)
|
|
206
|
-
[path, stat.ino, stat.mtime.to_f, stat.ctime.to_f, stat.size,
|
|
206
|
+
[path, stat.ino, stat.mtime.to_f, stat.ctime.to_f, stat.size, digest]
|
|
207
207
|
end
|
|
208
208
|
|
|
209
209
|
def provision(env, manifest, fetch, warn, platform)
|
|
@@ -257,8 +257,8 @@ module RubyRaider
|
|
|
257
257
|
nil
|
|
258
258
|
end
|
|
259
259
|
|
|
260
|
-
def verified?(path,
|
|
261
|
-
Digest::SHA256.file(path).hexdigest.casecmp?(
|
|
260
|
+
def verified?(path, digest)
|
|
261
|
+
Digest::SHA256.file(path).hexdigest.casecmp?(digest)
|
|
262
262
|
end
|
|
263
263
|
|
|
264
264
|
def executable_file?(path)
|
data/lib/ruby_raider.rb
CHANGED
|
@@ -52,7 +52,9 @@ module RubyRaider
|
|
|
52
52
|
map 'u' => 'utility'
|
|
53
53
|
|
|
54
54
|
no_tasks do
|
|
55
|
+
# -p values arrive as strings; only examples:false turns the example tests off
|
|
55
56
|
def generate_from_parameters(params)
|
|
57
|
+
params[:examples] = params[:examples].to_s.downcase != 'false' if params.key?(:examples)
|
|
56
58
|
InvokeGenerators.generate_framework(params)
|
|
57
59
|
end
|
|
58
60
|
|
|
@@ -18,6 +18,7 @@ module ScaffoldProjectDetector
|
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
CONFIG_PATH = 'config/config.yml'
|
|
21
|
+
WAIT_HELPER_PATH = 'helpers/wait_helper.rb'
|
|
21
22
|
|
|
22
23
|
class Error < StandardError; end
|
|
23
24
|
|
|
@@ -61,6 +62,11 @@ module ScaffoldProjectDetector
|
|
|
61
62
|
detect_automation == 'watir'
|
|
62
63
|
end
|
|
63
64
|
|
|
65
|
+
# Projects made before WaitHelper, or whose owner removed it, do not have the file.
|
|
66
|
+
def wait_helper?
|
|
67
|
+
File.exist?(WAIT_HELPER_PATH)
|
|
68
|
+
end
|
|
69
|
+
|
|
64
70
|
# Validates that the current directory looks like a Ruby Raider project.
|
|
65
71
|
# Returns an array of warning messages (empty = valid project).
|
|
66
72
|
def validate_project
|
|
@@ -92,6 +92,10 @@ class Scaffolding < Thor::Group
|
|
|
92
92
|
automation_type == 'watir'
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
def wait_helper?
|
|
96
|
+
ScaffoldProjectDetector.wait_helper?
|
|
97
|
+
end
|
|
98
|
+
|
|
95
99
|
def default_path(standard_path, file_type)
|
|
96
100
|
path ? "#{path}/#{normalized_name}#{file_type}" : standard_path
|
|
97
101
|
end
|
|
@@ -12,7 +12,11 @@ class <%= class_name %> < Component
|
|
|
12
12
|
private
|
|
13
13
|
|
|
14
14
|
# Elements
|
|
15
|
+
<%- if wait_helper? -%>
|
|
16
|
+
# Example: component.find_element(css: '.selector').click
|
|
17
|
+
<%- else -%>
|
|
15
18
|
# Example: driver.find_element(css: '.selector')
|
|
19
|
+
<%- end -%>
|
|
16
20
|
<%- elsif watir? -%>
|
|
17
21
|
|
|
18
22
|
def content
|
|
@@ -18,7 +18,11 @@ require_relative '../abstract/page'
|
|
|
18
18
|
|
|
19
19
|
<%= ' ' * module_parts.size %> # Elements
|
|
20
20
|
<%- if selenium? -%>
|
|
21
|
+
<%- if wait_helper? -%>
|
|
22
|
+
<%= ' ' * module_parts.size %> # Example: click({ id: 'element_id' })
|
|
23
|
+
<%- else -%>
|
|
21
24
|
<%= ' ' * module_parts.size %> # Example: driver.find_element(id: 'element_id')
|
|
25
|
+
<%- end -%>
|
|
22
26
|
<%- elsif watir? -%>
|
|
23
27
|
<%= ' ' * module_parts.size %> # Example: browser.text_field(id: 'element_id')
|
|
24
28
|
<%- end -%>
|
|
@@ -40,7 +44,11 @@ class <%= page_class_name %> < Page
|
|
|
40
44
|
|
|
41
45
|
# Elements
|
|
42
46
|
<%- if selenium? -%>
|
|
47
|
+
<%- if wait_helper? -%>
|
|
48
|
+
# Example: click({ id: 'element_id' })
|
|
49
|
+
<%- else -%>
|
|
43
50
|
# Example: driver.find_element(id: 'element_id')
|
|
51
|
+
<%- end -%>
|
|
44
52
|
<%- elsif watir? -%>
|
|
45
53
|
# Example: browser.text_field(id: 'element_id')
|
|
46
54
|
<%- end -%>
|
data/lib/version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.12.0
|
|
@@ -16,6 +16,30 @@ RSpec.describe RubyRaider::Raider do # rubocop:disable RSpec/FilePath
|
|
|
16
16
|
hash_including(framework: 'rspec', automation: 'selenium', name: 'my_project')
|
|
17
17
|
)
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
it 'passes examples:false through as false' do
|
|
21
|
+
allow(InvokeGenerators).to receive(:generate_framework)
|
|
22
|
+
|
|
23
|
+
described_class.new.invoke(:new, nil, %w[my_project -p framework:rspec automation:selenium examples:false])
|
|
24
|
+
|
|
25
|
+
expect(InvokeGenerators).to have_received(:generate_framework).with(hash_including(examples: false))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'keeps the examples by default' do
|
|
29
|
+
allow(InvokeGenerators).to receive(:generate_framework)
|
|
30
|
+
|
|
31
|
+
described_class.new.invoke(:new, nil, %w[my_project -p framework:rspec automation:selenium])
|
|
32
|
+
|
|
33
|
+
expect(InvokeGenerators).to have_received(:generate_framework).with(hash_excluding(:examples))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'keeps the examples for any value other than false' do
|
|
37
|
+
allow(InvokeGenerators).to receive(:generate_framework)
|
|
38
|
+
|
|
39
|
+
described_class.new.invoke(:new, nil, %w[my_project -p framework:rspec automation:selenium examples:true])
|
|
40
|
+
|
|
41
|
+
expect(InvokeGenerators).to have_received(:generate_framework).with(hash_including(examples: true))
|
|
42
|
+
end
|
|
19
43
|
end
|
|
20
44
|
|
|
21
45
|
context 'without parameters' do
|