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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/reek.yml +5 -1
  3. data/.github/workflows/release.yml +2 -1
  4. data/.github/workflows/system_tests.yml +1 -1
  5. data/.reek.yml +13 -0
  6. data/README.md +43 -4
  7. data/lib/commands/utility_commands.rb +1 -1
  8. data/lib/generators/automation/automation_generator.rb +15 -10
  9. data/lib/generators/automation/templates/appium_caps.tt +2 -4
  10. data/lib/generators/automation/templates/home.tt +1 -2
  11. data/lib/generators/automation/templates/login.tt +6 -6
  12. data/lib/generators/automation/templates/page.tt +10 -3
  13. data/lib/generators/automation/templates/partials/home_page_selector.tt +4 -6
  14. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +4 -6
  15. data/lib/generators/automation/templates/pdp.tt +3 -2
  16. data/lib/generators/cucumber/cucumber_generator.rb +15 -2
  17. data/lib/generators/cucumber/templates/partials/driver_world.tt +9 -0
  18. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +1 -1
  19. data/lib/generators/cucumber/templates/partials/selenium_env.tt +4 -0
  20. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
  21. data/lib/generators/generator.rb +15 -4
  22. data/lib/generators/helper_generator.rb +8 -0
  23. data/lib/generators/invoke_generators.rb +23 -0
  24. data/lib/generators/menu_generator.rb +26 -14
  25. data/lib/generators/rspec/rspec_generator.rb +10 -3
  26. data/lib/generators/rspec/templates/spec.tt +15 -1
  27. data/lib/generators/template_renderer/template_error.rb +8 -8
  28. data/lib/generators/templates/common/partials/mobile_config.tt +1 -0
  29. data/lib/generators/templates/common/partials/web_config.tt +1 -1
  30. data/lib/generators/templates/common/read_me.tt +508 -24
  31. data/lib/generators/templates/helpers/partials/selenium_driver.tt +1 -3
  32. data/lib/generators/templates/helpers/raider_element.tt +159 -0
  33. data/lib/generators/templates/helpers/session_state.tt +45 -5
  34. data/lib/generators/templates/helpers/spec_helper.tt +9 -1
  35. data/lib/generators/templates/helpers/wait_helper.tt +470 -0
  36. data/lib/ruby_raider/ffmpeg.rb +7 -7
  37. data/lib/ruby_raider.rb +2 -0
  38. data/lib/scaffolding/project_detector.rb +6 -0
  39. data/lib/scaffolding/scaffolding.rb +4 -0
  40. data/lib/scaffolding/templates/component.tt +4 -0
  41. data/lib/scaffolding/templates/page_object.tt +8 -0
  42. data/lib/version +1 -1
  43. data/sig/generators/generator.rbs +2 -0
  44. data/sig/scaffolding/project_detector.rbs +1 -0
  45. data/sig/scaffolding/scaffolding.rbs +1 -0
  46. data/spec/commands/raider_commands_spec.rb +24 -0
  47. data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +175 -0
  48. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +24 -0
  49. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +39 -0
  50. data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +33 -0
  51. data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +174 -0
  52. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +39 -0
  53. data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +33 -0
  54. data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +49 -0
  55. data/spec/integration/commands/scaffolding_commands_spec.rb +49 -0
  56. data/spec/integration/commands/utility_commands_spec.rb +8 -0
  57. data/spec/integration/content/config_content_spec.rb +44 -0
  58. data/spec/integration/content/helper_content_spec.rb +79 -0
  59. data/spec/integration/content/page_content_spec.rb +75 -4
  60. data/spec/integration/content/readme_content_spec.rb +126 -5
  61. data/spec/integration/content/syntax_validation_spec.rb +23 -0
  62. data/spec/integration/content/test_content_spec.rb +57 -0
  63. data/spec/integration/end_to_end_spec.rb +50 -28
  64. data/spec/integration/generators/automation_generator_spec.rb +50 -0
  65. data/spec/integration/generators/common_generator_spec.rb +20 -0
  66. data/spec/integration/generators/config_features_spec.rb +2 -2
  67. data/spec/integration/generators/cucumber_generator_spec.rb +36 -0
  68. data/spec/integration/generators/helpers_generator_spec.rb +38 -0
  69. data/spec/integration/generators/invoke_generators_spec.rb +70 -0
  70. data/spec/integration/generators/rspec_generator_spec.rb +20 -0
  71. data/spec/integration/helpers/session_state_spec.rb +46 -0
  72. data/spec/integration/helpers/wait_helper_mobile_spec.rb +281 -0
  73. data/spec/integration/helpers/wait_helper_spec.rb +496 -0
  74. data/spec/integration/helpers/wait_helper_support.rb +183 -0
  75. data/spec/integration/settings_helper.rb +4 -1
  76. data/spec/integration/spec_helper.rb +2 -2
  77. data/spec/menus/menu_generator_spec.rb +64 -2
  78. data/spec/scaffolding/scaffold_project_detector_spec.rb +12 -0
  79. 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
@@ -192,18 +192,18 @@ module RubyRaider
192
192
  [nil, true]
193
193
  end
194
194
 
195
- def verified_cached?(path, sha256)
196
- key = verification_key(path, sha256)
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, sha256)
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, sha256)
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, sha256]
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, sha256)
261
- Digest::SHA256.file(path).hexdigest.casecmp?(sha256)
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.11.0
1
+ 3.12.0
@@ -19,6 +19,8 @@ class Generator < Thor::Group
19
19
  def github_ci?: () -> bool
20
20
  def selenium?: () -> bool
21
21
  def watir?: () -> bool
22
+ def wait_helper?: () -> bool
23
+ def examples?: () -> bool
22
24
  def web?: () -> bool
23
25
  def selenium_based?: () -> bool
24
26
 
@@ -9,5 +9,6 @@ module ScaffoldProjectDetector
9
9
  def self?.read_gemfile: () -> String
10
10
  def self?.selenium?: () -> bool
11
11
  def self?.watir?: () -> bool
12
+ def self?.wait_helper?: () -> bool
12
13
  def self?.config: () -> Hash[String, untyped]
13
14
  end
@@ -25,5 +25,6 @@ class Scaffolding < Thor::Group
25
25
  def project_config: () -> Hash[String, untyped]
26
26
  def selenium?: () -> bool
27
27
  def watir?: () -> bool
28
+ def wait_helper?: () -> bool
28
29
  def default_path: (String standard_path, String file_type) -> String
29
30
  end
@@ -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