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
@@ -19,6 +19,10 @@
19
19
  <p align="center"> For more information and updates on releases, see <a href="https://ruby-raider.com">https://ruby-raider.com</a></p>
20
20
  </div>
21
21
 
22
+ <%- if watir? -%>
23
+ > **Deprecated:** Watir is deprecated and will be removed in the next major version of ruby_raider; new projects should use Selenium. Watir is only available through `-p automation:watir`. See [Migrating from Watir](https://github.com/RaiderHQ/ruby_raider#migrating-from-watir).
24
+
25
+ <%- end -%>
22
26
  ## What is Ruby Raider?
23
27
 
24
28
  Ruby Raider is a generator and scaffolding gem to make UI test automation easier.
@@ -82,6 +86,510 @@ raider g steps [NAME] # Create step definitions
82
86
  raider g scaffold [NAME] # Create page + test + steps
83
87
  ```
84
88
 
89
+ <%- if wait_helper? -%>
90
+ <%- test_cmd = rspec? ? 'rspec' : 'cucumber' -%>
91
+ ## How the framework works
92
+
93
+ This section walks through the project: where things live, how a test finds an element and acts on it, and how to
94
+ <%- if examples? -%>
95
+ run the tests. Most examples come from the files in this project.
96
+ <%- else -%>
97
+ run the tests.
98
+
99
+ This project was generated without example tests, so its <%= mobile? ? 'pages' : 'pages, components' %> and tests folders are empty.
100
+ Grow it with the scaffolding commands above: `raider g scaffold checkout` writes a `Checkout` page and its
101
+ <%= rspec? ? 'spec' : 'feature and steps' %>, and `raider g page`<%= rspec? ? ' and `raider g spec`' : ', `raider g feature` and `raider g steps`' %> write one file at a time. The code examples
102
+ below show what goes in them.
103
+ <%- end -%>
104
+
105
+ Two Ruby words you will see below: a **class** is a named group of methods, and a **method** is what other
106
+ languages call a function (it starts with `def` and ends with `end`).
107
+
108
+ ### Folder layout
109
+
110
+ - `config/`: the settings the tests read.
111
+ <%- if mobile? -%>
112
+ `capabilities.yml` describes the device and the app<%= cross_platform? ? ', and `config.yml` holds the other settings' : '' %>.
113
+ See "Settings" below.
114
+ <%- if examples? -%>
115
+ - `page_objects/pages/`: one class per screen of the app. `home.rb` is the product list and `pdp.rb` the product
116
+ details page. A page holds the locators of its elements and the actions a test can do on that screen.
117
+ <%- else -%>
118
+ - `page_objects/pages/`: one class per screen of the app, empty for now. A page holds the locators of its elements
119
+ and the actions a test can do on that screen.
120
+ <%- end -%>
121
+ - `page_objects/abstract/`: `page.rb`, the base class every page builds on. It gives each page the wait helper.
122
+ - `helpers/`: `driver_helper.rb` starts the Appium session, `wait_helper.rb` and `raider_element.rb` are the wait
123
+ helper, and `video_helper.rb` records the screen<%= cross_platform? ? '. `appium_helper.rb` has `element(ios: …, android: …)`, a lookup that does not wait' : '' %>.
124
+ <%- if rspec? -%>
125
+ `spec_helper.rb` sets up RSpec: it starts the app before each test and closes it after.
126
+ <%- if examples? -%>
127
+ - `spec/`: the tests. `pdp_page_spec.rb` opens a product and checks its "Add to Cart" button.
128
+ <%- else -%>
129
+ - `spec/`: the tests, empty for now.
130
+ <%- end -%>
131
+ <%- elsif examples? -%>
132
+ - `features/`: the tests. `home.feature` describes them in plain English, `step_definitions/home_steps.rb` holds the
133
+ Ruby code behind each step, and `support/` starts the app (`env.rb`) and makes the helpers available to the steps
134
+ (`world.rb`).
135
+ <%- else -%>
136
+ - `features/`: the tests, empty for now. A `.feature` file describes them in plain English, `step_definitions/` holds
137
+ the Ruby code behind each step, and `support/` starts the app (`env.rb`) and makes the helpers available to the
138
+ steps (`world.rb`).
139
+ <%- end -%>
140
+ <%- else -%>
141
+ `config.yml` is described under "Settings" below.
142
+ <%- if examples? -%>
143
+ - `page_objects/pages/`: one class per page of the site. `login.rb` is the login page and `account.rb` the account
144
+ page. A page holds the locators of its elements and the actions a test can do on that page.
145
+ - `page_objects/components/`: parts that appear on many pages. `header.rb` reads the welcome message at the top.
146
+ <%- else -%>
147
+ - `page_objects/pages/`: one class per page of the site, empty for now. A page holds the locators of its elements
148
+ and the actions a test can do on that page.
149
+ - `page_objects/components/`: parts that appear on many pages, such as a header, empty for now.
150
+ <%- end -%>
151
+ - `page_objects/abstract/`: `page.rb` and `component.rb`, the base classes every page and component builds on.
152
+ `page.rb` gives each page the wait helper.
153
+ - `helpers/`: `driver_helper.rb` starts the browser, `wait_helper.rb` and `raider_element.rb` are the wait helper,
154
+ `video_helper.rb` records failure videos and `session_state.rb` reuses the login.
155
+ <%- if rspec? -%>
156
+ `spec_helper.rb` sets up RSpec: it opens the browser before each test and closes it after.
157
+ <%- end -%>
158
+ <%- if examples? -%>
159
+ - `models/`: test data. `data/users.yml` holds the users, and `model_factory.rb`<%= cucumber? ? ' and `user_factory.rb` load' : ' loads' %> them.
160
+ <%- end -%>
161
+ <%- if rspec? && examples? -%>
162
+ - `spec/`: the tests. `login_page_spec.rb` logs in with the right password and with a wrong one.
163
+ <%- elsif rspec? -%>
164
+ - `spec/`: the tests, empty for now.
165
+ <%- elsif examples? -%>
166
+ - `features/`: the tests. `login.feature` describes them in plain English, `step_definitions/login_steps.rb` holds
167
+ the Ruby code behind each step, and `support/` opens the browser (`env.rb`) and makes the helpers available to the
168
+ steps (`world.rb`).
169
+ <%- else -%>
170
+ - `features/`: the tests, empty for now. A `.feature` file describes them in plain English, `step_definitions/`
171
+ holds the Ruby code behind each step, and `support/` opens the browser (`env.rb`) and makes the helpers available
172
+ to the steps (`world.rb`).
173
+ <%- end -%>
174
+ <%- end -%>
175
+ <%- if azure? && !mobile? -%>
176
+ - `azure-pipelines.yml`: the CI pipeline.
177
+ <%- elsif !azure? -%>
178
+ - `.github/workflows/`: the CI pipeline.
179
+ <%- end -%>
180
+
181
+ ### Settings
182
+
183
+ <%- if mobile? -%>
184
+ <%- if cross_platform? -%>
185
+ `config/capabilities.yml` tells Appium which device to use and which app to install. The capabilities for each
186
+ platform sit under the `ios:` and `android:` parts. In each part, `platformName` sits at the top, and the rest sits
187
+ under `appium:options`: `platformVersion`, `deviceName`, `automationName` (`XCUITest` on iOS, `UiAutomator2` on
188
+ Android) and `app`, the path to the app file.
189
+ <%- else -%>
190
+ `config/capabilities.yml` tells Appium which device to use and which app to install. `platformName` sits at the
191
+ top, and the rest sits under `appium:options`: `platformVersion`, `deviceName`, `automationName` (`XCUITest` on
192
+ iOS, `UiAutomator2` on Android) and `app`, the path to the app file.
193
+ <%- end -%>
194
+ The `browserstack` part is used instead when you run with `TEST_ENV=browserstack`.
195
+
196
+ <%- if cross_platform? -%>
197
+ `config/config.yml`:
198
+
199
+ - `platform`: which part of `capabilities.yml` to use, `ios` or `android`.
200
+ - `timeout`: how many seconds the wait helper waits for an element before the test fails (10). To wait longer,
201
+ change it, for example to `timeout: 15`.
202
+ - `video`: failure videos, see [Video replay](#video-replay).
203
+ <%- else -%>
204
+ This project has no `config/config.yml`, so the wait helper waits up to 10 seconds for an element before the test
205
+ fails. To change that, pass `timeout:` to one call (see below), or create `config/config.yml` with this line:
206
+
207
+ ```yaml
208
+ timeout: 15
209
+ ```
210
+ <%- end -%>
211
+ <%- else -%>
212
+ `config/config.yml`:
213
+
214
+ - `browser`: the browser the tests run in, `chrome` or `firefox`.
215
+ <%- if examples? -%>
216
+ - `url`: the address of the site under test. Each page adds its own path to it (`Login` adds
217
+ `index.php?rt=account/login`).
218
+ <%- else -%>
219
+ - `url`: the address of the site under test. Each page adds its own path to it. Change it to your site.
220
+ <%- end -%>
221
+ - `timeout`: how many seconds the wait helper waits for an element before the test fails (10).
222
+ - `viewport`: the browser window's width and height.
223
+ - `browser_arguments`: extra options passed to each browser when it starts.
224
+ - `headless`: not in the file by default. Add `headless: true`, or set `HEADLESS=true` for one run, to run the
225
+ browser without a window.
226
+ - `video`: failure videos, see [Video replay](#video-replay).
227
+ - `login_state`: how long a saved login is reused, see [Reusing the login](#reusing-the-login).
228
+ <%- end -%>
229
+
230
+ ### Pages and components
231
+
232
+ A page keeps two kinds of methods: **elements**, which say where something is, and **actions**, which do something
233
+ <%- if examples? -%>
234
+ with it. From `page_objects/pages/<%= mobile? ? 'home.rb' : 'login.rb' %>`:
235
+ <%- else -%>
236
+ with it. For example, on a <%= mobile? ? 'product list screen' : 'login page' %>:
237
+ <%- end -%>
238
+
239
+ ```ruby
240
+ <%- if mobile? -%>
241
+ def go_to_product_detail
242
+ tap(first_product)
243
+ end
244
+
245
+ def first_product
246
+ <%- if cross_platform? -%>
247
+ { ios: { accessibility_id: 'product-item-1' },
248
+ android: { accessibility_id: 'product-item-1' } }
249
+ <%- else -%>
250
+ { accessibility_id: 'product-item-1' }
251
+ <%- end -%>
252
+ end
253
+ <%- else -%>
254
+ def login(username, password)
255
+ send_keys(username_field, username)
256
+ send_keys(password_field, password)
257
+ click(login_button)
258
+ end
259
+
260
+ def username_field
261
+ { id: 'loginFrm_loginname' }
262
+ end
263
+ <%- end -%>
264
+ ```
265
+
266
+ <%- if mobile? -%>
267
+ An element method returns a **locator**: a Ruby hash (a key and a value in curly braces) that tells Appium how to
268
+ find the element. `{ accessibility_id: 'product-item-1' }` means "the element whose accessibility id is
269
+ `product-item-1`". `xpath`, `id` and `class_name` work too.
270
+ <%- if cross_platform? -%>
271
+
272
+ Because this project runs on both iOS and Android, a locator can name one for each: `{ ios: { … }, android: { … } }`.
273
+ The wait helper picks the one for the device the test runs on.
274
+ <%- end -%>
275
+
276
+ The action, `tap(first_product)`, comes from the wait helper: it waits until the product is ready, then taps it.
277
+ <%- else -%>
278
+ An element method returns a **locator**: a Ruby hash (a key and a value in curly braces) that tells Selenium how to
279
+ find the element. `{ id: 'loginFrm_loginname' }` means "the element whose id is `loginFrm_loginname`". `css`,
280
+ `xpath`, `name`, `class` and `link_text` work too.
281
+
282
+ The actions, `send_keys` and `click`, come from the wait helper: each one waits until the element is ready, then
283
+ types or clicks.
284
+
285
+ <%- if examples? -%>
286
+ A **component** is a part of the page that has its own methods. `page.rb` finds the welcome message and hands it to
287
+ the `Header` component:
288
+ <%- else -%>
289
+ A **component** is a part of the page that has its own methods. It builds on `page_objects/abstract/component.rb`,
290
+ and a page hands it the element it wraps. For a `Header` component in `page_objects/components/`:
291
+ <%- end -%>
292
+
293
+ ```ruby
294
+ def header
295
+ Header.new(find_element(css: '.welcome-msg'))
296
+ end
297
+ ```
298
+
299
+ `Header#customer_name` then reads its text, so a test can write `account_page.header.customer_name`.
300
+ <%- end -%>
301
+
302
+ ### The wait helper and RaiderElement
303
+
304
+ <%= mobile? ? 'Apps' : 'Pages' %> load at their own speed, so a test that acts too early fails. The wait helper,
305
+ `helpers/wait_helper.rb`, waits for an element to be ready before it acts. It checks every 0.1 seconds, for up to
306
+ `timeout` seconds. If the element is still not ready, the test fails and says what it saw last:
307
+
308
+ ```
309
+ <%- if mobile? -%>
310
+ Waited 10s for {accessibility_id: "product-item-1"} to be visible, enabled and stable; last seen: not found
311
+ <%- else -%>
312
+ Waited 10s for {id: "loginFrm_loginname"} to be visible and enabled; last seen: present but not visible
313
+ <%- end -%>
314
+ ```
315
+
316
+ You can use its methods in every page and in every <%= rspec? ? 'test' : 'step definition' %>:
317
+
318
+ | Method | Waits until the element is | Then |
319
+ |--------|----------------------------|------|
320
+ | `find_element(locator)` | present and visible (`visible: false` skips visible) | returns it as a RaiderElement |
321
+ | `find_elements(locator)` | present, at least one | returns a list of RaiderElements, or `[]` if none showed up |
322
+ <%- if mobile? -%>
323
+ | `click(locator)`, `tap(locator)` | visible, enabled and still | taps it (`tap` and `click` do the same) |
324
+ | `send_keys(locator, 'text')` | visible and enabled | types the text |
325
+ | `clear(locator)` | visible and enabled | empties the field |
326
+ | `swipe('left', locator)` | visible | swipes on it: `'up'`, `'down'`, `'left'` or `'right'` |
327
+ | `scroll('down', locator)` | visible | scrolls inside it |
328
+ | `scroll_to(locator)` | visible, scrolling down up to 10 times | returns it as a RaiderElement |
329
+ <%- else -%>
330
+ | `click(locator)` | visible, enabled and still | clicks it |
331
+ | `send_keys(locator, 'text')` | visible and enabled | types the text |
332
+ | `clear(locator)` | visible and enabled | empties the field |
333
+ | `submit(locator)` | visible and enabled | submits its form |
334
+ | `select_by(locator, :text, 'Blue')` | visible and enabled | picks a dropdown option by `:text`, `:value` or `:index` |
335
+ | `hover(locator)` | visible and still | moves the mouse over it |
336
+ | `drag_and_drop(source, target)` | source visible, enabled and still; target visible | drags the source onto the target |
337
+ | `scroll_into_view(locator)` | present | scrolls the page to it |
338
+ <%- end -%>
339
+ | `text(locator)`, `attribute(locator, 'name')` | present | returns its text or the attribute's value |
340
+ | `displayed?(locator)`, `enabled?(locator)`, `selected?(locator)` | present | returns `true` or `false` |
341
+ | `eventually { … }` | (see below) | retries the block until it passes |
342
+
343
+ Good to know:
344
+
345
+ <%- if mobile? -%>
346
+ - Write the locator with or without braces: `tap(accessibility_id: 'login')` and `tap({ accessibility_id: 'login' })`
347
+ are the same. When a method takes more than the locator, put the locator in braces:
348
+ `send_keys({ accessibility_id: 'email' }, 'me@example.com')`, `swipe('left', { accessibility_id: 'card' })`.
349
+ <%- else -%>
350
+ - Write the locator with or without braces: `click(id: 'login')` and `click({ id: 'login' })` are the same. When a
351
+ method takes more than the locator, put the locator in braces: `send_keys({ id: 'email' }, 'me@example.com')`.
352
+ <%- end -%>
353
+ - `timeout:` changes the wait for one call, in seconds: `<%= mobile? ? "tap({ accessibility_id: 'slow-button' }, timeout: 30)" : "click({ id: 'slow-button' }, timeout: 30)" %>`.
354
+ - "Still" (`stable` in error messages) means the element was in the same place on two checks in a row, 0.1 seconds
355
+ apart, so it is not in the middle of an animation. That is why <%= mobile? ? '`click` and `tap`' : '`click`, `hover` and `drag_and_drop`' %> need at least two checks, and `timeout: 0` never passes for them.
356
+ - `text`, `displayed?` and the other readers fail when the element never shows up. To check that something is
357
+ **not** on the <%= mobile? ? 'screen' : 'page' %>, ask for the list without waiting and check that it is empty:
358
+
359
+ ```ruby
360
+ eventually { expect(find_elements(<%= mobile? ? "accessibility_id: 'error'" : "css: '.error'" %>, timeout: 0)).to be_empty }
361
+ ```
362
+ <%- if mobile? -%>
363
+ - `swipe` and `scroll` with a locator wait for that element to be visible and act on it. Without a locator
364
+ (`swipe('up')`) they act on the whole screen and return `nil`.
365
+ - `scroll_to(locator)` checks for the element, scrolls once if it is not visible, and checks again, up to
366
+ `max_scrolls` times (10), then fails. Change the direction with `direction: 'up'`. An element that appears late
367
+ near the top can be scrolled past, so first wait for something on the screen, for example with `find_element`.
368
+ - `tap` with a block and no locator (`page.tap { |p| … }`) is still Ruby's own `tap`, which passes the object to the
369
+ block.
370
+ - `hide_keyboard`, `back` and the other Appium commands are on `driver`, and do not wait.
371
+ <%- end -%>
372
+
373
+ `find_element` returns a **RaiderElement**: the element, plus the same waiting methods without the locator. So
374
+ calls chain, and each step waits:
375
+
376
+ ```ruby
377
+ <%- if mobile? -%>
378
+ find_element(accessibility_id: 'list').find_element(accessibility_id: 'row').tap
379
+ find_element(accessibility_id: 'card').swipe('left')
380
+ <%- else -%>
381
+ find_element(css: '.form').find_element(id: 'email').send_keys('me@example.com')
382
+ <%- end -%>
383
+ ```
384
+
385
+ A `find_element` on a RaiderElement searches only inside it. If the <%= mobile? ? 'app' : 'page' %> redraws the element, the RaiderElement
386
+ finds it again by its locator. Any other <%= mobile? ? 'Appium' : 'Selenium' %> method (`tag_name`, `rect`, `location`, …) goes straight to the
387
+ element, without waiting.
388
+
389
+ A RaiderElement is not a <%= mobile? ? 'raw Appium' : 'Selenium' %> element, so the <%= mobile? ? 'Appium' : 'Selenium' %> calls that check what they are given need
390
+ the real one, which `.native` returns:
391
+
392
+ ```ruby
393
+ <%- if mobile? -%>
394
+ driver.action.click(find_element(accessibility_id: 'card').native).perform
395
+ <%- else -%>
396
+ driver.switch_to.frame(find_element(css: 'iframe').native)
397
+ Selenium::WebDriver::Support::Select.new(find_element(id: 'color').native)
398
+ driver.action.move_to(find_element(id: 'menu').native).perform
399
+ driver.execute_script('arguments[0].click()', find_element(id: 'go').native)
400
+ <%- end -%>
401
+ ```
402
+ <%- if mobile? -%>
403
+
404
+ Elements you pass to `execute_script` need `.native` too.
405
+ <%- end -%>
406
+
407
+ ### eventually
408
+
409
+ `eventually { … }` runs the block again and again until it passes, or until `timeout` seconds have gone by. It
410
+ retries a failed `expect`, an element that was not found, an element that went stale (was redrawn), and
411
+ a wait inside it that timed out. It returns what the block returns, and `eventually(timeout: 30) { … }` waits longer.
412
+
413
+ Use it for checks on something that changes after an action. Before, the check runs once, and fails if the
414
+ <%= mobile? ? 'screen' : 'page' %> has not updated yet:
415
+
416
+ ```ruby
417
+ <%- if mobile? -%>
418
+ expect(pdp_page.add_to_cart_text).to eq 'Add to Cart'
419
+ <%- else -%>
420
+ expect(account_page.header.customer_name).to eq "Welcome back #{user['name']}"
421
+ <%- end -%>
422
+ ```
423
+
424
+ After, it is retried until the text matches:
425
+
426
+ ```ruby
427
+ <%- if mobile? -%>
428
+ eventually { expect(pdp_page.add_to_cart_text).to eq 'Add to Cart' }
429
+ <%- else -%>
430
+ eventually { expect(account_page.header.customer_name).to eq "Welcome back #{user['name']}" }
431
+ <%- end -%>
432
+ ```
433
+
434
+ If it still fails at the end, the error starts with `(still failing after 10s)`, followed by the last failure, with
435
+ its own error class. The waits inside the block share its time limit, so the whole block never takes longer than
436
+ `timeout`. A wait without its own `timeout:` gets whatever is left of the block's time, so inside
437
+ `eventually(timeout: 30) { … }` it can wait up to 30 seconds, not just the default. A wait with its own
438
+ `timeout: 2` gets 2 seconds per try, and the block is tried again until its own time is up.
439
+
440
+ ### Running the tests
441
+
442
+ <%- if mobile? -%>
443
+ Start Appium and a simulator or emulator first (see "Setting up Appium" below). Then:
444
+ <%- else -%>
445
+ Install the gems once with `bundle install`. Then:
446
+ <%- end -%>
447
+
448
+ ```bash
449
+ <%- if rspec? -%>
450
+ bundle exec rspec # every test
451
+ bundle exec rspec spec/<%= examples? ? (mobile? ? 'pdp_page_spec.rb' : 'login_page_spec.rb') : 'checkout_page_spec.rb' %> # one file
452
+ <%- else -%>
453
+ bundle exec cucumber # every scenario
454
+ bundle exec cucumber features/<%= examples? ? (mobile? ? 'home.feature' : 'login.feature') : 'checkout.feature' %> # one feature
455
+ <%- end -%>
456
+ <%- unless mobile? -%>
457
+ HEADLESS=true bundle exec <%= test_cmd %> # without a browser window
458
+ <%- end -%>
459
+ ```
460
+
461
+ <%- if mobile? -%>
462
+ - To run tests in parallel, each process needs its own device or simulator. This project does not set that up, so
463
+ run the tests one at a time unless you add it.
464
+ <%- else -%>
465
+ - In parallel, with the `parallel_tests` gem in the Gemfile: `bundle exec <%= rspec? ? 'parallel_rspec spec/' : 'parallel_cucumber features/' %>`. Each process opens its own
466
+ browser.
467
+ <%- end -%>
468
+ <%- if rspec? -%>
469
+ - `RETRY_COUNT=2 bundle exec rspec` runs each test up to 2 times, so a failing test gets one more try before it
470
+ counts as failed.
471
+ <%- else -%>
472
+ - To run a failing scenario again before it counts as failed, change `--retry 0` in `cucumber.yml` to, for
473
+ example, `--retry 2`.
474
+ <%- end -%>
475
+
476
+ ### Running in CI
477
+
478
+ <%- if azure? && mobile? -%>
479
+ There is no Azure pipeline for mobile projects yet; see [CI](#ci).
480
+ <%- elsif azure? -%>
481
+ `azure-pipelines.yml` runs the suite headless on Azure Pipelines; see [Azure Pipelines](#azure-pipelines) to set it up.
482
+ <%- elsif mobile? -%>
483
+ `.github/workflows/appium_pipeline.yml` runs the suite on GitHub Actions on every push to `main` or `master` and on
484
+ every pull request. It downloads the test app and starts a simulator or emulator and Appium.
485
+ <%- else -%>
486
+ `.github/workflows/test_pipeline.yml` runs the suite headless on GitHub Actions on every push to `main` or `master`
487
+ and on every pull request, and uploads `raider-results/`.
488
+ <%- end -%>
489
+
490
+ Every run writes a report; see [Reporting](#reporting).
491
+ <%- if web? -%>
492
+ To log in once and reuse the session across tests, see [Reusing the login](#reusing-the-login).
493
+ <%- end -%>
494
+ <%- if mobile? -%>
495
+
496
+ ### Setting up Appium
497
+
498
+ 1. Install Appium and its driver: `npm install -g appium`, then
499
+ `appium driver install <%= android? ? 'uiautomator2' : 'xcuitest' %>`<%= cross_platform? ? ' and `appium driver install uiautomator2`' : '' %>.
500
+ 2. Download the example [app](https://github.com/RaiderHQ/raider_test_app) and put it in the project folder.
501
+ `app` in `config/capabilities.yml` is its path<%= cross_platform? ? ' (`RaiderTestApp.app` for iOS, `app-release.apk` for Android)' : (ios? ? ' (`RaiderTestApp.app`)' : ' (`app-release.apk`)') %>.
502
+ 3. Start a<%= android? ? 'n Android emulator' : (ios? ? 'n iOS simulator' : ' simulator or an emulator') %> that matches `deviceName` and `platformVersion`.
503
+ 4. Start the Appium server with `raider u start_appium` (plain `appium`), and keep it running while the tests run.
504
+ The tests connect to it at `http://127.0.0.1:4723`, Appium's default address.
505
+ <%- end -%>
506
+
507
+ ### Removing the wait helper
508
+
509
+ The wait helper is ordinary code in your project, so you can take it out. To do that:
510
+
511
+ 1. Delete `helpers/wait_helper.rb` and `helpers/raider_element.rb`.
512
+ 2. Remove `include WaitHelper` from `page_objects/abstract/page.rb`, <%= rspec? ? '`config.include(WaitHelper)` from `helpers/spec_helper.rb`' : '`World(WaitHelper)` from `features/support/world.rb`' %>, and the
513
+ `require_relative` lines that load `wait_helper`.
514
+ 3. Replace the calls:
515
+ - `find_element(loc)` with `driver.find_element(**loc)`;
516
+ - `click(loc)` and `send_keys(loc, …)` with `driver.find_element(**loc).click` and `.send_keys(…)`;
517
+ - `eventually { … }` with a plain `expect`, or `Selenium::WebDriver::Wait.new(timeout: 10).until { condition }`
518
+ with a block that returns true or false (`until` does not retry a failing `expect`).
519
+ <%- if cross_platform? -%>
520
+ - Per-OS locators (`{ ios: { … }, android: { … } }`) with `element(ios: { … }, android: { … })` from
521
+ `helpers/appium_helper.rb`.
522
+ <%- end -%>
523
+ <%- if mobile? -%>
524
+ 4. Optionally turn on Appium's implicit wait, so plain lookups wait for the element to be present: in
525
+ `configure_driver` in `helpers/driver_helper.rb`, pass `appium_lib: { wait: 10 }` next to `caps:` in
526
+ `Appium::Driver.new`.
527
+ <%- else -%>
528
+ 4. Optionally put back an implicit wait, `driver.manage.timeouts.implicit_wait = 10`, in `create_driver` in
529
+ `helpers/driver_helper.rb`, so plain lookups wait for the element to be present.
530
+ <%- end -%>
531
+ 5. Scaffolding (`raider g page` and `raider g component`) then writes plain `driver.find_element` examples on its
532
+ own, because it checks for `helpers/wait_helper.rb`.
533
+
534
+ <%- end -%>
535
+ <%- if web? -%>
536
+ ## Reusing the login
537
+
538
+ <%- if examples? -%>
539
+ Tag an example `:logged_in` (RSpec) or a scenario `@logged_in` (Cucumber) and the suite logs in once, saves the
540
+ session, and restores it for the next tagged example or run instead of filling in the login form again. The generated
541
+ account example shows it. If the saved session no longer works, the real login runs again and the state is saved anew.
542
+ <%- else -%>
543
+ `helpers/session_state.rb` logs in once, saves the session, and restores it for the next test or run instead of
544
+ filling in the login form again. If the saved session no longer works, the real login runs again and the state is
545
+ saved anew.
546
+
547
+ This project has no login page yet. Once you have one (`raider g page login`), add a hook so that every
548
+ <%= rspec? ? 'example tagged `:logged_in`' : 'scenario tagged `@logged_in`' %> starts logged in. In `<%= rspec? ? 'helpers/spec_helper.rb' : 'features/support/env.rb' %>`, require your pages next to
549
+ the other `require_relative` lines, then add the hook<%= rspec? ? ' inside `RSpec.configure`' : '' %>:
550
+
551
+ ```ruby
552
+ <%- if rspec? -%>
553
+ require_relative '../page_objects/pages/login'
554
+ require_relative '../page_objects/pages/dashboard'
555
+
556
+ config.before(:each, :logged_in) do
557
+ <%- else -%>
558
+ require_relative '../../page_objects/pages/login'
559
+ require_relative '../../page_objects/pages/dashboard'
560
+
561
+ Before('@logged_in') do
562
+ <%- end -%>
563
+ dashboard = Dashboard.new(driver)
564
+ SessionState.new(driver).reuse(valid: -> { dashboard.tap(&:visit).logged_in? }) do
565
+ login = Login.new(driver)
566
+ login.visit
567
+ login.login(ENV.fetch('TEST_USER'), ENV.fetch('TEST_PASSWORD'))
568
+ end
569
+ end
570
+ ```
571
+
572
+ `Login` and `Dashboard` stand for your own pages: `valid:` checks that a restored session is still logged in (here a
573
+ `logged_in?` method you write on the page), and the block does the real login.
574
+ <%- end -%>
575
+
576
+ For another role, call it yourself with its own name:
577
+
578
+ ```ruby
579
+ SessionState.new(driver, name: 'admin').reuse(valid: -> { dashboard.tap(&:visit).admin? }) do
580
+ # the real login for the admin
581
+ end
582
+ ```
583
+
584
+ With Watir, pass `browser.driver`, the Selenium driver underneath the browser.
585
+
586
+ - `login_state: max_age:` in `config/config.yml` is how many seconds a saved login stays valid (default 3600).
587
+ - `RAIDER_LOGIN_STATE=off` always does the real login and saves nothing; `RAIDER_LOGIN_STATE=refresh` ignores the saved
588
+ file, logs in and saves again.
589
+ - The state lives in `.raider/auth/` and is git-ignored. It holds live session tokens, so it must not be committed,
590
+ cached or uploaded as a CI artifact. Nothing in it is ever printed.
591
+
592
+ <%- end -%>
85
593
  ## Reporting
86
594
 
87
595
  <%- if rspec? -%>
@@ -193,30 +701,6 @@ would exit 2 on every run.
193
701
  Parallel test jobs that upload to one build must each set `RAIDER_SHARD_TOTAL` (the number of jobs) and
194
702
  `RAIDER_SHARD_INDEX` (1 to that number), so the gate waits for all of them instead of judging the first one to finish.
195
703
 
196
- <%- end -%>
197
- <%- if web? -%>
198
- ## Reusing the login
199
-
200
- Tag an example `:logged_in` (RSpec) or a scenario `@logged_in` (Cucumber) and the suite logs in once, saves the
201
- session, and restores it for the next tagged example or run instead of filling in the login form again. The generated
202
- account example shows it. If the saved session no longer works, the real login runs again and the state is saved anew.
203
-
204
- For another role, call it yourself with its own name:
205
-
206
- ```ruby
207
- SessionState.new(driver, name: 'admin').reuse(valid: -> { dashboard.tap(&:visit).admin? }) do
208
- # the real login for the admin
209
- end
210
- ```
211
-
212
- With Watir, pass `browser.driver`, the Selenium driver underneath the browser.
213
-
214
- - `login_state: max_age:` in `config/config.yml` is how many seconds a saved login stays valid (default 3600).
215
- - `RAIDER_LOGIN_STATE=off` always does the real login and saves nothing; `RAIDER_LOGIN_STATE=refresh` ignores the saved
216
- file, logs in and saves again.
217
- - The state lives in `.raider/auth/` and is git-ignored. It holds live session tokens, so it must not be committed,
218
- cached or uploaded as a CI artifact. Nothing in it is ever printed.
219
-
220
704
  <%- end -%>
221
705
  <%- if azure? && mobile? -%>
222
706
  ## CI
@@ -1,27 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AppiumHelper
2
4
  def element(opts = {})
3
- return driver.find_element(strategy(opts) => selector(opts)) if opts[os]
5
+ return driver.find_element(strategy(opts) => selector(opts)) if opts[os]
4
6
 
5
- driver.find_element(opts)
7
+ driver.find_element(opts)
6
8
  end
7
9
 
8
10
  def elements(opts = {})
9
- return driver.find_elements(strategy(opts) => selector(opts)) if opts[os]
11
+ return driver.find_elements(strategy(opts) => selector(opts)) if opts[os]
10
12
 
11
- driver.find_elements(opts)
12
- end
13
+ driver.find_elements(opts)
14
+ end
13
15
 
14
- def os
15
- driver.appium_device
16
- end
16
+ def os
17
+ driver.appium_device
18
+ end
17
19
 
18
- private
20
+ private
19
21
 
20
- def strategy(opts)
21
- opts[os].keys.first
22
- end
22
+ def strategy(opts)
23
+ opts[os].keys.first
24
+ end
23
25
 
24
- def selector(opts)
25
- opts[os][strategy(opts)]
26
- end
26
+ def selector(opts)
27
+ opts[os][strategy(opts)]
28
+ end
27
29
  end
@@ -6,7 +6,6 @@ require 'watir'
6
6
  require_relative 'video_helper'
7
7
 
8
8
  module BrowserHelper
9
-
10
9
  def browser(*args)
11
10
  @browser ||= create_browser(*args)
12
11
  end
@@ -23,7 +22,7 @@ module BrowserHelper
23
22
  args = args.empty? ? config['browser_arguments'][browser_name] : args
24
23
  args += ['--headless'] if (ENV['HEADLESS'] || config['headless']) && !args.include?('--headless')
25
24
  capitalized = browser_name == 'ie' ? browser_name.upcase : browser_name.capitalize
26
- browser_options = "Selenium::WebDriver::#{capitalized}::Options".constantize.new(args:)
25
+ browser_options = "Selenium::WebDriver::#{capitalized}::Options".constantize.new(args: args)
27
26
  arguments = { options: browser_options }
28
27
  listener = VideoHelper.listener
29
28
  arguments[:listener] = listener if listener
@@ -32,7 +32,7 @@
32
32
  end
33
33
  <%- end -%>
34
34
 
35
- <%= partial('browserstack_config', strip: true) %>
35
+ <%= partial('browserstack_config', strip: true) %>
36
36
 
37
37
  def configure_driver
38
38
  if browserstack?
@@ -4,9 +4,7 @@
4
4
  arguments = { options: create_webdriver_options(*opts) }
5
5
  listener = VideoHelper.listener
6
6
  arguments[:listener] = listener if listener
7
- driver = Selenium::WebDriver.for(browser.to_sym, **arguments)
8
- driver.manage.timeouts.implicit_wait = @config.fetch('timeout', 10)
9
- driver
7
+ Selenium::WebDriver.for(browser.to_sym, **arguments)
10
8
  end
11
9
 
12
10
  def browser_arguments(*opts)