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.
- 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/.ruby-version +1 -1
- 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 +18 -6
- data/lib/generators/automation/templates/partials/home_page_selector.tt +4 -6
- data/lib/generators/automation/templates/partials/initialize_selector.tt +3 -3
- data/lib/generators/automation/templates/partials/pdp_page_selector.tt +4 -6
- data/lib/generators/automation/templates/partials/url_methods.tt +3 -2
- data/lib/generators/automation/templates/partials/visit_method.tt +5 -5
- data/lib/generators/automation/templates/pdp.tt +3 -2
- data/lib/generators/cucumber/cucumber_generator.rb +16 -3
- data/lib/generators/cucumber/templates/env.tt +3 -3
- 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/cucumber/templates/steps.tt +2 -2
- data/lib/generators/cucumber/templates/user_factory.tt +2 -0
- data/lib/generators/cucumber/templates/world.tt +5 -3
- data/lib/generators/generator.rb +29 -14
- 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/accessibility_spec.tt +10 -10
- data/lib/generators/rspec/templates/spec.tt +17 -2
- data/lib/generators/template_renderer/template_error.rb +8 -8
- data/lib/generators/templates/common/gemfile.tt +9 -9
- 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/appium_helper.tt +17 -15
- data/lib/generators/templates/helpers/browser_helper.tt +1 -2
- data/lib/generators/templates/helpers/partials/appium_driver.tt +1 -1
- 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 +46 -6
- data/lib/generators/templates/helpers/spec_helper.tt +10 -2
- data/lib/generators/templates/helpers/wait_helper.tt +470 -0
- data/lib/ruby_raider/ffmpeg.rb +7 -7
- data/lib/ruby_raider.rb +9 -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 +4 -0
- data/sig/ruby_raider.rbs +1 -0
- data/sig/scaffolding/project_detector.rbs +1 -0
- data/sig/scaffolding/scaffolding.rbs +1 -0
- data/spec/commands/raider_commands_spec.rb +48 -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 +37 -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 +37 -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/content_helper.rb +1 -5
- 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/rubocop_content_spec.rb +87 -0
- data/spec/integration/content/syntax_validation_spec.rb +33 -30
- data/spec/integration/content/test_content_spec.rb +60 -0
- data/spec/integration/end_to_end_spec.rb +73 -28
- data/spec/integration/frozen_string_literal_matcher.rb +13 -0
- 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 +130 -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/scaffolding_e2e_spec.rb +1 -5
- 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 +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)
|