ruby_raider 3.11.0 → 3.12.1

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 (97) 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/.ruby-version +1 -1
  7. data/README.md +43 -4
  8. data/lib/commands/utility_commands.rb +1 -1
  9. data/lib/generators/automation/automation_generator.rb +15 -10
  10. data/lib/generators/automation/templates/appium_caps.tt +2 -4
  11. data/lib/generators/automation/templates/home.tt +1 -2
  12. data/lib/generators/automation/templates/login.tt +6 -6
  13. data/lib/generators/automation/templates/page.tt +18 -6
  14. data/lib/generators/automation/templates/partials/home_page_selector.tt +4 -6
  15. data/lib/generators/automation/templates/partials/initialize_selector.tt +3 -3
  16. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +4 -6
  17. data/lib/generators/automation/templates/partials/url_methods.tt +3 -2
  18. data/lib/generators/automation/templates/partials/visit_method.tt +5 -5
  19. data/lib/generators/automation/templates/pdp.tt +3 -2
  20. data/lib/generators/cucumber/cucumber_generator.rb +16 -3
  21. data/lib/generators/cucumber/templates/env.tt +3 -3
  22. data/lib/generators/cucumber/templates/partials/driver_world.tt +9 -0
  23. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +1 -1
  24. data/lib/generators/cucumber/templates/partials/selenium_env.tt +4 -0
  25. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
  26. data/lib/generators/cucumber/templates/steps.tt +2 -2
  27. data/lib/generators/cucumber/templates/user_factory.tt +2 -0
  28. data/lib/generators/cucumber/templates/world.tt +5 -3
  29. data/lib/generators/generator.rb +29 -14
  30. data/lib/generators/helper_generator.rb +8 -0
  31. data/lib/generators/invoke_generators.rb +23 -0
  32. data/lib/generators/menu_generator.rb +26 -14
  33. data/lib/generators/rspec/rspec_generator.rb +10 -3
  34. data/lib/generators/rspec/templates/accessibility_spec.tt +10 -10
  35. data/lib/generators/rspec/templates/spec.tt +17 -2
  36. data/lib/generators/template_renderer/template_error.rb +8 -8
  37. data/lib/generators/templates/common/gemfile.tt +9 -9
  38. data/lib/generators/templates/common/partials/mobile_config.tt +1 -0
  39. data/lib/generators/templates/common/partials/web_config.tt +1 -1
  40. data/lib/generators/templates/common/read_me.tt +508 -24
  41. data/lib/generators/templates/helpers/appium_helper.tt +17 -15
  42. data/lib/generators/templates/helpers/browser_helper.tt +1 -2
  43. data/lib/generators/templates/helpers/partials/appium_driver.tt +1 -1
  44. data/lib/generators/templates/helpers/partials/selenium_driver.tt +1 -3
  45. data/lib/generators/templates/helpers/raider_element.tt +159 -0
  46. data/lib/generators/templates/helpers/session_state.tt +46 -6
  47. data/lib/generators/templates/helpers/spec_helper.tt +10 -2
  48. data/lib/generators/templates/helpers/wait_helper.tt +470 -0
  49. data/lib/ruby_raider/ffmpeg.rb +7 -7
  50. data/lib/ruby_raider.rb +9 -0
  51. data/lib/scaffolding/project_detector.rb +6 -0
  52. data/lib/scaffolding/scaffolding.rb +4 -0
  53. data/lib/scaffolding/templates/component.tt +4 -0
  54. data/lib/scaffolding/templates/page_object.tt +8 -0
  55. data/lib/version +1 -1
  56. data/sig/generators/generator.rbs +4 -0
  57. data/sig/ruby_raider.rbs +1 -0
  58. data/sig/scaffolding/project_detector.rbs +1 -0
  59. data/sig/scaffolding/scaffolding.rbs +1 -0
  60. data/spec/commands/raider_commands_spec.rb +48 -0
  61. data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +175 -0
  62. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +24 -0
  63. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +37 -0
  64. data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +33 -0
  65. data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +174 -0
  66. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +37 -0
  67. data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +33 -0
  68. data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +49 -0
  69. data/spec/integration/commands/scaffolding_commands_spec.rb +49 -0
  70. data/spec/integration/commands/utility_commands_spec.rb +8 -0
  71. data/spec/integration/content/config_content_spec.rb +44 -0
  72. data/spec/integration/content/content_helper.rb +1 -5
  73. data/spec/integration/content/helper_content_spec.rb +79 -0
  74. data/spec/integration/content/page_content_spec.rb +75 -4
  75. data/spec/integration/content/readme_content_spec.rb +126 -5
  76. data/spec/integration/content/rubocop_content_spec.rb +87 -0
  77. data/spec/integration/content/syntax_validation_spec.rb +33 -30
  78. data/spec/integration/content/test_content_spec.rb +60 -0
  79. data/spec/integration/end_to_end_spec.rb +73 -28
  80. data/spec/integration/frozen_string_literal_matcher.rb +13 -0
  81. data/spec/integration/generators/automation_generator_spec.rb +50 -0
  82. data/spec/integration/generators/common_generator_spec.rb +20 -0
  83. data/spec/integration/generators/config_features_spec.rb +2 -2
  84. data/spec/integration/generators/cucumber_generator_spec.rb +36 -0
  85. data/spec/integration/generators/helpers_generator_spec.rb +38 -0
  86. data/spec/integration/generators/invoke_generators_spec.rb +130 -0
  87. data/spec/integration/generators/rspec_generator_spec.rb +20 -0
  88. data/spec/integration/helpers/session_state_spec.rb +46 -0
  89. data/spec/integration/helpers/wait_helper_mobile_spec.rb +281 -0
  90. data/spec/integration/helpers/wait_helper_spec.rb +496 -0
  91. data/spec/integration/helpers/wait_helper_support.rb +183 -0
  92. data/spec/integration/scaffolding_e2e_spec.rb +1 -5
  93. data/spec/integration/settings_helper.rb +4 -1
  94. data/spec/integration/spec_helper.rb +2 -2
  95. data/spec/menus/menu_generator_spec.rb +64 -2
  96. data/spec/scaffolding/scaffold_project_detector_spec.rb +12 -0
  97. metadata +17 -2
@@ -0,0 +1,496 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'wait_helper_support'
4
+
5
+ WaitHelperSpec.load_templates(mobile: false)
6
+
7
+ module WaitHelperSpec
8
+ class Page
9
+ include WaitHelper
10
+
11
+ attr_reader :driver
12
+
13
+ def initialize(driver)
14
+ @driver = driver
15
+ end
16
+ end
17
+
18
+ # A <select> answering Support::Select's option queries.
19
+ class FakeSelect < FakeElement
20
+ OPTIONS = [%w[Red red], %w[Blue blue], %w[Green green]].freeze
21
+
22
+ def initialize(**state)
23
+ super(tag_name: 'select', **state)
24
+ @options = OPTIONS.each_with_index.map do |(label, value), index|
25
+ FakeElement.new(text: label, attributes: { 'value' => value }, index:)
26
+ end
27
+ end
28
+
29
+ attr_reader :options
30
+
31
+ def find_elements(xpath: nil, tag_name: nil, **locator)
32
+ return super(**locator) unless xpath || tag_name
33
+ return @options if tag_name
34
+
35
+ text = xpath[/normalize-space\(\.\) = "(.*)"\]/, 1]
36
+ value = xpath[/@value = "(.*)"\]/, 1]
37
+ @options.select { |option| option.text == text || option.attribute('value') == value }
38
+ end
39
+ end
40
+ end
41
+
42
+ describe 'WaitHelper' do
43
+ let(:driver) { WaitHelperSpec::FakeDriver.new }
44
+ let(:page) { WaitHelperSpec::Page.new(driver) }
45
+
46
+ def element(**state) = WaitHelperSpec::FakeElement.new(**state)
47
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ def errors = Selenium::WebDriver::Error
49
+
50
+ describe '#find_element' do
51
+ it 'returns the element once it is present and visible' do
52
+ found = element
53
+ driver.script({ id: 'x' }, nil, element(displayed: false), found)
54
+ expect(page.find_element({ id: 'x' }, timeout: 1)).to eq(found)
55
+ expect(driver.finds.size).to eq(3)
56
+ end
57
+
58
+ it 'returns a hidden element with visible: false' do
59
+ hidden = element(displayed: false)
60
+ driver.script({ id: 'x' }, hidden)
61
+ expect(page.find_element({ id: 'x' }, visible: false, timeout: 1)).to eq(hidden)
62
+ end
63
+
64
+ it 'symbolizes a string-keyed locator' do
65
+ driver.script({ id: 'x' }, element)
66
+ page.find_element({ 'id' => 'x' }, timeout: 1)
67
+ expect(driver.finds).to eq([{ id: 'x' }])
68
+ end
69
+
70
+ it 'raises ArgumentError at once for an empty or a two-key locator' do
71
+ started = clock
72
+ expect { page.find_element({}) }.to raise_error(ArgumentError)
73
+ expect { page.find_element({ id: 'a', css: 'b' }) }.to raise_error(ArgumentError)
74
+ expect(clock - started).to be < 0.1
75
+ end
76
+
77
+ it 'makes one attempt at timeout: 0' do
78
+ driver.script({ id: 'hidden' }, element(displayed: false))
79
+ expect { page.find_element({ id: 'hidden' }, timeout: 0) }
80
+ .to raise_error(WaitHelper::TimeoutError, /last seen: present but not visible/)
81
+ expect(driver.finds.size).to eq(1)
82
+ end
83
+ end
84
+
85
+ describe '#find_elements' do
86
+ it 'returns every match, hidden ones included, once one is present' do
87
+ shown = element
88
+ hidden = element(displayed: false)
89
+ driver.script_many({ css: 'li' }, [], [shown, hidden])
90
+ expect(page.find_elements({ css: 'li' }, timeout: 1)).to eq([shown, hidden])
91
+ end
92
+
93
+ it 'returns [] at the timeout without raising' do
94
+ expect(page.find_elements({ css: 'li' }, timeout: 0.2)).to eq([])
95
+ end
96
+ end
97
+
98
+ describe '#click' do
99
+ it 'waits through not found, hidden, disabled and moving, then clicks once' do
100
+ target = element(rects: [[0, 0, 10, 10], [5, 5, 10, 10], [5, 5, 10, 10]])
101
+ driver.script({ id: 'go' }, nil, element(displayed: false), element(enabled: false), target)
102
+ page.click({ id: 'go' }, timeout: 2)
103
+ expect(target.log).to eq([[:click]])
104
+ expect(driver.finds.size).to eq(6)
105
+ end
106
+
107
+ it 'retries intercepted, stale and not-interactable clicks, re-finding by locator' do
108
+ target = element(errors: [errors::ElementClickInterceptedError.new('covered'),
109
+ errors::StaleElementReferenceError.new('stale'),
110
+ errors::ElementNotInteractableError.new('not interactable')])
111
+ driver.script({ id: 'go' }, target)
112
+ page.click({ id: 'go' }, timeout: 3)
113
+ expect(target.log).to eq([[:click]])
114
+ expect(driver.finds.size).to be > 3
115
+ end
116
+
117
+ it 'raises InvalidSelectorError on the first attempt' do
118
+ driver.script({ css: '[[' }, errors::InvalidSelectorError)
119
+ expect { page.click({ css: '[[' }, timeout: 1) }.to raise_error(errors::InvalidSelectorError)
120
+ expect(driver.finds.size).to eq(1)
121
+ end
122
+
123
+ it 'resolves a per-OS locator with appium_device' do
124
+ android = WaitHelperSpec::FakeDriver.new(device: :android)
125
+ android.script({ accessibility_id: 'b' }, element)
126
+ per_os = { ios: { accessibility_id: 'a' }, android: { accessibility_id: 'b' } }
127
+ WaitHelperSpec::Page.new(android).click(per_os, timeout: 1)
128
+ expect(android.finds.uniq).to eq([{ accessibility_id: 'b' }])
129
+ end
130
+
131
+ it 'raises a stale passed element at once, suggesting a locator' do
132
+ passed = element
133
+ passed.go_stale
134
+ expect { page.click(passed, timeout: 2) }.to raise_error(errors::StaleElementReferenceError, /pass .*locator/)
135
+ end
136
+ end
137
+
138
+ describe 'typing and forms' do
139
+ it 'passes every key to send_keys' do
140
+ field = element
141
+ driver.script({ id: 'x' }, field)
142
+ page.send_keys({ id: 'x' }, 'a', :tab, timeout: 1)
143
+ expect(field.log).to eq([[:send_keys, 'a', :tab]])
144
+ end
145
+
146
+ it 'clears and submits once visible and enabled, returning the element' do
147
+ field = element
148
+ driver.script({ id: 'x' }, element(enabled: false), field)
149
+ expect(page.clear({ id: 'x' }, timeout: 1)).to eq(field)
150
+ expect(page.submit({ id: 'x' }, timeout: 1)).to eq(field)
151
+ expect(field.log).to eq([[:clear], [:submit]])
152
+ end
153
+
154
+ it 'selects by text, value and index through Support::Select' do
155
+ dropdown = WaitHelperSpec::FakeSelect.new
156
+ driver.script({ id: 'colour' }, dropdown)
157
+ page.select_by({ id: 'colour' }, :text, 'Blue', timeout: 1)
158
+ page.select_by({ id: 'colour' }, :value, 'green', timeout: 1)
159
+ page.select_by({ id: 'colour' }, :index, 0, timeout: 1)
160
+ expect(dropdown.options.map(&:log)).to eq([[[:click]], [[:click]], [[:click]]])
161
+ end
162
+ end
163
+
164
+ describe 'actions' do
165
+ it 'hovers with move_to once the element is stable' do
166
+ menu = element
167
+ driver.script({ id: 'menu' }, menu)
168
+ page.hover({ id: 'menu' }, timeout: 1)
169
+ expect(driver.actions).to eq([[:move_to, menu], [:perform]])
170
+ end
171
+
172
+ it 'drags the source onto the target' do
173
+ source = element
174
+ target = element
175
+ driver.script({ id: 'src' }, source).script({ id: 'tgt' }, target)
176
+ expect(page.drag_and_drop({ id: 'src' }, { id: 'tgt' }, timeout: 1)).to eq(source)
177
+ expect(driver.actions).to eq([[:drag_and_drop, source, target], [:perform]])
178
+ end
179
+
180
+ it 'scrolls a hidden but present element into view' do
181
+ below = element(displayed: false)
182
+ driver.script({ id: 'below' }, below)
183
+ page.scroll_into_view({ id: 'below' }, timeout: 1)
184
+ expect(driver.actions).to eq([[:scroll_to, below], [:perform]])
185
+ end
186
+ end
187
+
188
+ describe 'readers' do
189
+ it 'answer for a present element, hidden included' do
190
+ driver.script({ id: 'x' }, element(displayed: false, text: 'Hello', attributes: { 'href' => '/a' }))
191
+ expect(page.text({ id: 'x' }, timeout: 1)).to eq('Hello')
192
+ expect(page.attribute({ id: 'x' }, 'href', timeout: 1)).to eq('/a')
193
+ expect(page.displayed?({ id: 'x' }, timeout: 1)).to be(false)
194
+ expect(page.enabled?({ id: 'x' }, timeout: 1)).to be(true)
195
+ expect(page.selected?({ id: 'x' }, timeout: 1)).to be(false)
196
+ end
197
+
198
+ it 'raise the timeout error when nothing matches' do
199
+ expect { page.text({ id: 'none' }, timeout: 0.2) }.to raise_error(WaitHelper::TimeoutError)
200
+ expect { page.displayed?({ id: 'none' }, timeout: 0.2) }.to raise_error(WaitHelper::TimeoutError)
201
+ end
202
+ end
203
+
204
+ describe 'braceless locators' do
205
+ it 'treat keywords as the locator' do
206
+ driver.script({ css: '.x' }, element(tag_name: 'span')).script({ id: 'y' }, element).script({ id: 'z' }, element)
207
+ expect(page.find_element(css: '.x').tag_name).to eq('span')
208
+ page.click(id: 'y')
209
+ expect(page.text(id: 'z')).to eq('hi')
210
+ expect(driver.finds.uniq).to eq([{ css: '.x' }, { id: 'y' }, { id: 'z' }])
211
+ end
212
+ end
213
+
214
+ describe 'timeout errors' do
215
+ it 'subclass Selenium TimeoutError, describe the wait and start the backtrace at the caller' do
216
+ driver.script({ id: 'x' }, element(displayed: false))
217
+ expect { page.send_keys({ id: 'x' }, 'a', timeout: 0.3) }.to raise_error(WaitHelper::TimeoutError) { |error|
218
+ expect(error).to be_a(Selenium::WebDriver::Error::TimeoutError)
219
+ expect(error.message)
220
+ .to eq('Waited 0.3s for {id: "x"} to be visible and enabled; last seen: present but not visible')
221
+ expect(error.backtrace.first).to include(File.basename(__FILE__))
222
+ expect(error.backtrace.first).not_to include('wait_helper.rb')
223
+ }
224
+ end
225
+ end
226
+
227
+ describe '#eventually' do
228
+ it 'returns the block value after two failing tries' do
229
+ tries = 0
230
+ result = page.eventually(timeout: 1) do
231
+ tries += 1
232
+ expect(tries).to eq(3)
233
+ :done
234
+ end
235
+ expect(result).to eq(:done)
236
+ end
237
+
238
+ it 're-raises the last expectation failure with the still-failing prefix' do
239
+ answer = 1
240
+ expect { page.eventually(timeout: 0.3) { expect(answer).to eq(2) } }
241
+ .to raise_error(RSpec::Expectations::ExpectationNotMetError) { |error|
242
+ expect(error.message).to start_with('(still failing after 0.3s)').and(include('expected: 2'))
243
+ }
244
+ end
245
+
246
+ it 'raises any other error on the first try' do
247
+ tries = 0
248
+ expect { page.eventually(timeout: 1) { raise "boom #{tries += 1}" } }.to raise_error(RuntimeError, 'boom 1')
249
+ end
250
+
251
+ it 'stretches a nested call without timeout: to what is left of its own deadline' do
252
+ allow(WaitHelper).to receive(:default_timeout).and_return(1)
253
+ driver.script({ css: '.x' }, *Array.new(15), element(text: 'y'))
254
+ tries = 0
255
+ started = clock
256
+ page.eventually(timeout: 3) do
257
+ tries += 1
258
+ expect(page.text(css: '.x')).to eq 'y'
259
+ end
260
+ expect([tries, clock - started]).to match([1, be_between(1.3, 2.9)])
261
+ end
262
+
263
+ it 'retries a nested timeout: per attempt and fails at its own deadline with the prefix' do
264
+ started = clock
265
+ expect { page.eventually(timeout: 1) { page.text({ css: '.never' }, timeout: 0.2) } }
266
+ .to raise_error(WaitHelper::TimeoutError, /\A\(still failing after 1s\)\nWaited [\d.]+s for \{css: "\.never"\}/)
267
+ expect(clock - started).to be_between(0.9, 1.4)
268
+ expect(driver.finds.size).to be > 5
269
+ end
270
+
271
+ it 'caps a nested action at its own deadline' do
272
+ started = clock
273
+ expect { page.eventually(timeout: 0.5) { page.click({ id: 'never' }, timeout: 5) } }
274
+ .to raise_error(WaitHelper::TimeoutError)
275
+ expect(clock - started).to be < 0.8
276
+ end
277
+ end
278
+
279
+ describe RaiderElement do
280
+ it 'chains finders, waiting at each level inside the parent' do
281
+ email = element
282
+ form = element.script({ id: 'email' }, nil, email)
283
+ driver.script({ css: '.form' }, nil, form)
284
+ page.find_element(css: '.form').find_element(id: 'email').send_keys('a')
285
+ expect(driver.finds).to eq([{ css: '.form' }] * 2)
286
+ expect(form.finds).to eq([{ id: 'email' }] * 2)
287
+ expect(email.log).to eq([[:send_keys, 'a']])
288
+ end
289
+
290
+ it 'returns an Array of wrappers from find_elements' do
291
+ driver.script_many({ css: 'li' }, [element, element])
292
+ expect(page.find_elements(css: 'li')).to all(be_a(described_class))
293
+ end
294
+
295
+ it 'delegates unwrapped methods to the raw element and compares by it' do
296
+ raw = element(tag_name: 'a')
297
+ driver.script({ id: 'x' }, raw)
298
+ wrapper = page.find_element(id: 'x')
299
+ other = page.find_element(id: 'x')
300
+ expect([wrapper.tag_name, wrapper.rect, wrapper.respond_to?(:tag_name)]).to eq(['a', [0, 0, 10, 10], true])
301
+ expect(wrapper.native).to be(raw)
302
+ expect([wrapper == raw, wrapper == other, [wrapper, other].uniq.size]).to eq([true, true, 1])
303
+ expect(wrapper).not_to be_a(Selenium::WebDriver::Element)
304
+ end
305
+
306
+ it 're-finds a stale wrapper through its scope' do
307
+ first = element
308
+ second = element
309
+ driver.script({ id: 'x' }, first, second)
310
+ wrapper = page.find_element(id: 'x')
311
+ first.go_stale
312
+ wrapper.click(timeout: 1)
313
+ expect([first.log, second.log, wrapper.native]).to eq([[], [[:click]], second])
314
+ end
315
+
316
+ it 'keeps its element after a timed-out action' do
317
+ raw = element(tag_name: 'a')
318
+ driver.script({ id: 'x' }, raw, nil)
319
+ wrapper = page.find_element(id: 'x')
320
+ hash = wrapper.hash
321
+ raw.go_hidden
322
+ expect { wrapper.click(timeout: 0.2) }.to raise_error(WaitHelper::TimeoutError)
323
+ expect([wrapper.native, wrapper.tag_name, wrapper == raw, wrapper.hash]).to eq([raw, 'a', true, hash])
324
+ end
325
+
326
+ it 'heals a chain when the parent was re-rendered' do
327
+ old_button = element
328
+ new_button = element
329
+ old_form = element.script({ id: 'go' }, old_button)
330
+ new_form = element.script({ id: 'go' }, new_button)
331
+ driver.script({ css: '.form' }, old_form, new_form)
332
+ button = page.find_element(css: '.form').find_element(id: 'go')
333
+ [old_form, old_button].each(&:go_stale)
334
+ button.click(timeout: 1)
335
+ expect(new_button.log).to eq([[:click]])
336
+ end
337
+
338
+ it 'raises a stale find_elements member with a re-query hint' do
339
+ item = element
340
+ driver.script_many({ css: 'li' }, [item])
341
+ member = page.find_elements(css: 'li').first
342
+ item.go_stale
343
+ expect { member.click(timeout: 1) }.to raise_error(errors::StaleElementReferenceError, /find_elements again/)
344
+ end
345
+
346
+ context 'when a parent that cannot be found again goes stale' do
347
+ let(:raw_row) { element.script({ css: 'td' }, element) }
348
+ let(:hint) { /find_elements again/ }
349
+
350
+ def stale_row
351
+ driver.script_many({ css: 'tr' }, [raw_row])
352
+ row = page.find_elements(css: 'tr').first
353
+ raw_row.go_stale
354
+ row
355
+ end
356
+
357
+ def at_once
358
+ started = clock
359
+ yield
360
+ expect(clock - started).to be < 0.3
361
+ end
362
+
363
+ it 'raises from find_elements at timeout: 0 instead of answering []' do
364
+ row = stale_row
365
+ expect { row.find_elements({ css: '.err' }, timeout: 0) }.to raise_error(errors::StaleElementReferenceError, hint)
366
+ end
367
+
368
+ it 'raises from find_elements at once instead of waiting and answering []' do
369
+ row = stale_row
370
+ at_once { expect { row.find_elements(css: 'td', timeout: 2) }.to raise_error(errors::StaleElementReferenceError, hint) }
371
+ end
372
+
373
+ it 'raises from find_element at once with the hint' do
374
+ row = stale_row
375
+ at_once { expect { row.find_element(css: 'td', timeout: 2) }.to raise_error(errors::StaleElementReferenceError, hint) }
376
+ end
377
+
378
+ it 'raises for a parent passed in as an element, suggesting a locator' do
379
+ parent = page.find_element(raw_row)
380
+ raw_row.go_stale
381
+ at_once do
382
+ expect { parent.find_element(css: 'td', timeout: 2) }
383
+ .to raise_error(errors::StaleElementReferenceError, /pass .*locator/)
384
+ end
385
+ end
386
+
387
+ it 'raises at once for a stale ancestor further up the chain' do
388
+ leaf = element
389
+ middle = element.script({ css: 'td' }, leaf)
390
+ raw_row.script({ css: '.cell' }, middle)
391
+ driver.script_many({ css: 'tr' }, [raw_row])
392
+ button = page.find_elements(css: 'tr').first.find_element(css: '.cell').find_element(css: 'td')
393
+ [raw_row, middle, leaf].each(&:go_stale)
394
+ at_once { expect { button.click(timeout: 2) }.to raise_error(errors::StaleElementReferenceError, hint) }
395
+ end
396
+
397
+ it 'still re-finds a stale child inside a live parent' do
398
+ old_button = element
399
+ new_button = element
400
+ raw_row.script({ css: 'button' }, old_button, new_button)
401
+ driver.script_many({ css: 'tr' }, [raw_row])
402
+ button = page.find_elements(css: 'tr').first.find_element(css: 'button')
403
+ old_button.go_stale
404
+ button.click(timeout: 1)
405
+ expect(new_button.log).to eq([[:click]])
406
+ end
407
+ end
408
+
409
+ it 'passes both raw elements to drag_and_drop' do
410
+ source = element
411
+ target = element
412
+ driver.script({ id: 'src' }, source).script({ id: 'tgt' }, target)
413
+ page.find_element(id: 'src').drag_and_drop(page.find_element(id: 'tgt'))
414
+ step = driver.actions.first
415
+ expect([step[1].equal?(source), step[2].equal?(target)]).to eq([true, true])
416
+ end
417
+
418
+ it 'adds exactly the planned public methods' do
419
+ expect(described_class.public_instance_methods(false).sort)
420
+ .to eq(%i[== attribute clear click displayed? drag_and_drop enabled? eql? find_element find_elements hash
421
+ hover native scroll_into_view select_by selected? send_keys submit text])
422
+ end
423
+ end
424
+
425
+ describe 'the choke point' do
426
+ let(:actions) { [] }
427
+
428
+ before do
429
+ dropdown = WaitHelperSpec::FakeSelect.new
430
+ dropdown.script({ css: 'y' }, element).script_many({ css: 'y' }, [element])
431
+ driver.script({ id: 'x' }, dropdown)
432
+ allow_any_instance_of(WaitHelper::Waiter).to receive(:perform_action).and_wrap_original do |original, *args, **options, &block|
433
+ actions << args.first
434
+ original.call(*args, **options, &block)
435
+ end
436
+ end
437
+
438
+ def calls(locator)
439
+ { find_element: [locator], find_elements: [locator], click: [locator], clear: [locator], submit: [locator],
440
+ hover: [locator], scroll_into_view: [locator], text: [locator], displayed?: [locator],
441
+ enabled?: [locator], selected?: [locator], send_keys: [locator, 'a'], select_by: [locator, :text, 'Blue'],
442
+ attribute: [locator, 'href'], drag_and_drop: [locator, locator] }
443
+ end
444
+
445
+ it 'is called once per mixin method, with the method name' do
446
+ calls({ id: 'x' }).each do |name, args|
447
+ actions.clear
448
+ page.public_send(name, *args, timeout: 1)
449
+ expect(actions).to eq([name])
450
+ end
451
+ end
452
+
453
+ it 'is called once per wrapper method, with the method name' do
454
+ wrapper = page.find_element(id: 'x')
455
+ calls({ css: 'y' }).each do |name, args|
456
+ args = args.drop(1) unless %i[find_element find_elements drag_and_drop].include?(name)
457
+ args = [wrapper] if name == :drag_and_drop
458
+ actions.clear
459
+ wrapper.public_send(name, *args, timeout: 1)
460
+ expect(actions).to eq([name])
461
+ end
462
+ end
463
+ end
464
+
465
+ describe 'footprint' do
466
+ it 'adds exactly the public API, with no private methods and nothing named wait' do
467
+ expect(WaitHelper.public_instance_methods(false).sort)
468
+ .to eq(%i[attribute clear click displayed? drag_and_drop enabled? eventually find_element find_elements hover
469
+ scroll_into_view select_by selected? send_keys submit text])
470
+ expect(WaitHelper.private_instance_methods(false)).to be_empty
471
+ expect(page.respond_to?(:wait, true)).to be(false)
472
+ end
473
+ end
474
+
475
+ describe '.default_timeout' do
476
+ around do |example|
477
+ Dir.mktmpdir do |dir|
478
+ Dir.chdir(dir) { example.run }
479
+ ensure
480
+ WaitHelper.instance_variable_set(:@default_timeout, nil)
481
+ end
482
+ end
483
+
484
+ it 'is 10 without config/config.yml' do
485
+ WaitHelper.instance_variable_set(:@default_timeout, nil)
486
+ expect(WaitHelper.default_timeout).to eq(10)
487
+ end
488
+
489
+ it 'reads timeout from config/config.yml' do
490
+ WaitHelper.instance_variable_set(:@default_timeout, nil)
491
+ Dir.mkdir('config')
492
+ File.write('config/config.yml', YAML.dump('timeout' => 3))
493
+ expect(WaitHelper.default_timeout).to eq(3)
494
+ end
495
+ end
496
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'rspec/expectations'
5
+ # The real gem by its inner paths, not 'selenium-webdriver': spec/generators/video_helper_spec.rb puts a
6
+ # stub selenium-webdriver.rb first on $LOAD_PATH, and the stub's Support module would mask the autoload.
7
+ require 'selenium/webdriver'
8
+ require 'selenium/webdriver/support'
9
+ require 'tmpdir'
10
+ require 'yaml'
11
+
12
+ # Renders the WaitHelper templates and fakes the drivers and elements they talk to.
13
+ module WaitHelperSpec
14
+ TEMPLATES = File.expand_path('../../../lib/generators/templates/helpers', __dir__)
15
+
16
+ # Answers the template's ERB conditionals the way the generator does.
17
+ class Render
18
+ def initialize(mobile)
19
+ @mobile = mobile
20
+ end
21
+
22
+ def mobile?
23
+ @mobile
24
+ end
25
+
26
+ def context
27
+ binding
28
+ end
29
+ end
30
+
31
+ # Renders both templates into one temp dir, so wait_helper.rb's require_relative finds raider_element.rb.
32
+ # The web render loads at the top level, as in a generated project. A mobile render loads inside
33
+ # `into` instead (load's wrap module), so both renders live in one process without clashing.
34
+ def self.load_templates(mobile: false, into: nil)
35
+ Dir.mktmpdir('raider_tt') do |dir|
36
+ paths = %w[raider_element wait_helper].to_h do |name|
37
+ source = File.read(File.join(TEMPLATES, "#{name}.tt"))
38
+ path = File.join(dir, "#{name}.rb")
39
+ File.write(path, ERB.new(source, trim_mode: '-').result(Render.new(mobile).context))
40
+ [name, path]
41
+ end
42
+ next load(paths['wait_helper']) unless into
43
+
44
+ # require_relative never wraps, so raider_element.rb is loaded into the namespace first
45
+ # and marked as required, which makes wait_helper.rb's require_relative a no-op.
46
+ load(paths['raider_element'], into)
47
+ $LOADED_FEATURES << File.realpath(paths['raider_element'])
48
+ load(paths['wait_helper'], into)
49
+ end
50
+ end
51
+
52
+ # Scripted lookups shared by the fake driver and fake elements. Each locator answers from its
53
+ # queue, one answer per call, repeating the last: nil is "no such element", an error class is raised.
54
+ module Finder
55
+ def script(locator, *answers)
56
+ @fake[:one][locator] = answers
57
+ self
58
+ end
59
+
60
+ def script_many(locator, *answers)
61
+ @fake[:many][locator] = answers
62
+ self
63
+ end
64
+
65
+ def finds = @fake[:finds]
66
+
67
+ def find_element(**locator)
68
+ finds << locator
69
+ answer = next_answer(:one, locator, nil)
70
+ raise answer if answer.is_a?(Class)
71
+ raise Selenium::WebDriver::Error::NoSuchElementError, 'no such element' unless answer
72
+
73
+ answer
74
+ end
75
+
76
+ def find_elements(**locator)
77
+ finds << locator
78
+ next_answer(:many, locator, [])
79
+ end
80
+
81
+ private
82
+
83
+ def next_answer(kind, locator, default)
84
+ queue = @fake[kind].fetch(locator) { [default] }
85
+ queue.size > 1 ? queue.shift : queue.first
86
+ end
87
+ end
88
+
89
+ # Records driver.action chains into the driver's log.
90
+ class FakeActions
91
+ def initialize(log)
92
+ @log = log
93
+ end
94
+
95
+ %i[move_to scroll_to drag_and_drop perform].each do |step|
96
+ define_method(step) do |*elements|
97
+ @log << [step, *elements]
98
+ self
99
+ end
100
+ end
101
+ end
102
+
103
+ # Selenium's driver, plus the Appium calls the mobile render makes.
104
+ class FakeDriver
105
+ include Finder
106
+
107
+ WINDOW = Selenium::WebDriver::Rectangle.new(0, 20, 390, 800)
108
+
109
+ def initialize(device: :ios)
110
+ @fake = { one: {}, many: {}, finds: [], actions: [], scripts: [], device: }
111
+ end
112
+
113
+ def appium_device = @fake[:device]
114
+ def actions = @fake[:actions]
115
+ def action = FakeActions.new(actions)
116
+ def scripts = @fake[:scripts]
117
+ def window_rect = WINDOW
118
+
119
+ def execute_script(command, *args)
120
+ scripts << [command, *args]
121
+ nil
122
+ end
123
+ end
124
+
125
+ class FakeElement
126
+ include Finder
127
+
128
+ STALE = Selenium::WebDriver::Error::StaleElementReferenceError
129
+
130
+ def initialize(**state)
131
+ @fake = { one: {}, many: {}, finds: [] }
132
+ @state = { displayed: true, enabled: true, selected: false, rects: [[0, 0, 10, 10]], text: 'hi',
133
+ attributes: {}, tag_name: 'div', errors: [], log: [], stale: false, id: "el-#{object_id}" }.merge(state)
134
+ end
135
+
136
+ def log = @state[:log]
137
+ def go_stale = @state[:stale] = true
138
+ def go_hidden = @state[:displayed] = false
139
+ def id = @state[:id]
140
+ def displayed? = live(@state[:displayed])
141
+ def enabled? = live(@state[:enabled])
142
+ def selected? = live(@state[:selected])
143
+ def text = live(@state[:text])
144
+ def tag_name = live(@state[:tag_name])
145
+ def attribute(name) = live(@state[:attributes][name])
146
+ def dom_attribute(name) = live(@state[:attributes][name.to_s])
147
+ def property(name) = live(@state[name.to_sym])
148
+
149
+ # A stale element cannot be searched inside, as in Selenium.
150
+ def find_element(**locator)
151
+ live(nil)
152
+ super
153
+ end
154
+
155
+ def find_elements(**locator)
156
+ live(nil)
157
+ super
158
+ end
159
+
160
+ def rect
161
+ rects = @state[:rects]
162
+ live(rects.size > 1 ? rects.shift : rects.first)
163
+ end
164
+
165
+ %i[click clear submit send_keys].each do |action|
166
+ define_method(action) do |*keys|
167
+ live(nil)
168
+ error = @state[:errors].shift
169
+ raise error if error
170
+
171
+ log << [action, *keys]
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ def live(value)
178
+ raise STALE, 'stale element reference' if @state[:stale]
179
+
180
+ value
181
+ end
182
+ end
183
+ end
@@ -4,13 +4,9 @@ require 'fileutils'
4
4
  require 'pathname'
5
5
  require_relative '../../lib/commands/scaffolding_commands'
6
6
  require_relative '../../lib/scaffolding/scaffolding'
7
+ require_relative 'frozen_string_literal_matcher'
7
8
 
8
9
  # Custom matchers (defined inline to avoid pulling in integration spec_helper)
9
- RSpec::Matchers.define :have_frozen_string_literal do
10
- match { |content| content.include?('# frozen_string_literal: true') }
11
- failure_message { 'expected file to contain frozen_string_literal magic comment' }
12
- end
13
-
14
10
  RSpec::Matchers.define :have_valid_ruby_syntax do
15
11
  match do |content|
16
12
  RubyVM::InstructionSequence.compile(content)