ruby_raider 3.13.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/runtime.yml +38 -0
  3. data/.github/workflows/system_tests.yml +1 -23
  4. data/.reek.yml +1 -0
  5. data/.rspec +1 -0
  6. data/Gemfile +2 -0
  7. data/README.md +16 -49
  8. data/RELEASE.md +22 -0
  9. data/bin/raider +5 -0
  10. data/lib/commands/scaffolding_commands.rb +2 -25
  11. data/lib/commands/utility_commands.rb +23 -3
  12. data/lib/generators/automation/automation_generator.rb +0 -5
  13. data/lib/generators/automation/templates/component.tt +1 -0
  14. data/lib/generators/automation/templates/home.tt +2 -2
  15. data/lib/generators/automation/templates/login.tt +6 -26
  16. data/lib/generators/automation/templates/page.tt +23 -18
  17. data/lib/generators/automation/templates/partials/home_page_selector.tt +1 -2
  18. data/lib/generators/automation/templates/partials/initialize_selector.tt +0 -8
  19. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +1 -2
  20. data/lib/generators/automation/templates/partials/url_methods.tt +8 -2
  21. data/lib/generators/automation/templates/partials/visit_method.tt +0 -5
  22. data/lib/generators/automation/templates/pdp.tt +1 -9
  23. data/lib/generators/common_generator.rb +0 -2
  24. data/lib/generators/generator.rb +12 -19
  25. data/lib/generators/helper_generator.rb +21 -19
  26. data/lib/generators/infrastructure/templates/azure_pipelines.tt +1 -1
  27. data/lib/generators/infrastructure/templates/github.tt +2 -3
  28. data/lib/generators/infrastructure/templates/github_appium.tt +0 -8
  29. data/lib/generators/invoke_generators.rb +4 -23
  30. data/lib/generators/menu_generator.rb +1 -9
  31. data/lib/generators/rspec/templates/accessibility_spec.tt +1 -9
  32. data/lib/generators/rspec/templates/spec.tt +8 -24
  33. data/lib/generators/template_renderer/partial_cache.rb +3 -1
  34. data/lib/generators/template_renderer.rb +1 -1
  35. data/lib/generators/templates/common/gemfile.tt +3 -10
  36. data/lib/generators/templates/common/git_ignore.tt +0 -5
  37. data/lib/generators/templates/common/partials/mobile_config.tt +3 -1
  38. data/lib/generators/templates/common/partials/web_config.tt +10 -1
  39. data/lib/generators/templates/common/rakefile.tt +0 -15
  40. data/lib/generators/templates/common/read_me.tt +357 -250
  41. data/lib/generators/templates/common/rubocop.tt +0 -2
  42. data/lib/generators/templates/helpers/driver_helper.tt +8 -0
  43. data/lib/generators/templates/helpers/gesture_helper.tt +78 -0
  44. data/lib/generators/templates/helpers/network_helper.tt +521 -0
  45. data/lib/generators/templates/helpers/partials/quit_driver.tt +0 -2
  46. data/lib/generators/templates/helpers/partials/screenshot.tt +0 -2
  47. data/lib/generators/templates/helpers/partials/selenium_driver.tt +5 -2
  48. data/lib/generators/templates/helpers/screenshot_helper.tt +490 -0
  49. data/lib/generators/templates/helpers/spec_helper.tt +39 -29
  50. data/lib/generators/templates/helpers/trace_helper.tt +240 -95
  51. data/lib/generators/templates/helpers/video_helper.tt +3 -8
  52. data/lib/ruby_raider/runtime/actionability.rb +129 -0
  53. data/lib/ruby_raider/runtime/actionable.rb +49 -0
  54. data/lib/ruby_raider/runtime/by_role.rb +55 -0
  55. data/lib/ruby_raider/runtime/config.rb +79 -0
  56. data/lib/ruby_raider/runtime/context.rb +38 -0
  57. data/lib/ruby_raider/runtime/errors.rb +69 -0
  58. data/lib/ruby_raider/runtime/finders.rb +73 -0
  59. data/lib/ruby_raider/runtime/js/actionability.js +134 -0
  60. data/lib/ruby_raider/runtime/js/role_engine.js +315 -0
  61. data/lib/ruby_raider/runtime/locator_converter.rb +72 -0
  62. data/lib/ruby_raider/runtime/matchers/base.rb +222 -0
  63. data/lib/ruby_raider/runtime/matchers/element.rb +183 -0
  64. data/lib/ruby_raider/runtime/matchers/page.rb +71 -0
  65. data/lib/ruby_raider/runtime/matchers/presence.rb +41 -0
  66. data/lib/ruby_raider/runtime/matchers/retrying_block.rb +104 -0
  67. data/lib/ruby_raider/runtime/observer.rb +170 -0
  68. data/lib/ruby_raider/runtime/role_finder.rb +83 -0
  69. data/lib/ruby_raider/runtime/rspec.rb +78 -0
  70. data/lib/ruby_raider/runtime/selectors.rb +60 -0
  71. data/lib/ruby_raider/runtime/strict.rb +142 -0
  72. data/lib/ruby_raider/runtime/wait.rb +84 -0
  73. data/lib/ruby_raider/runtime.rb +20 -0
  74. data/lib/scaffolding/name_normalizer.rb +1 -1
  75. data/lib/scaffolding/project_detector.rb +2 -28
  76. data/lib/scaffolding/scaffolding.rb +0 -20
  77. data/lib/scaffolding/templates/component.tt +0 -14
  78. data/lib/scaffolding/templates/page_object.tt +2 -14
  79. data/lib/scaffolding/templates/spec.tt +1 -9
  80. data/lib/utilities/utilities.rb +10 -8
  81. data/lib/version +1 -1
  82. data/ruby_raider.gemspec +2 -1
  83. data/sig/commands/scaffolding_commands.rbs +0 -2
  84. data/sig/commands/utility_commands.rbs +6 -0
  85. data/sig/generators/generator.rbs +0 -4
  86. data/sig/generators/helper_generator.rbs +0 -1
  87. data/sig/generators/menu_generator.rbs +0 -1
  88. data/sig/ruby_raider/runtime/actionability.rbs +25 -0
  89. data/sig/ruby_raider/runtime/actionable.rbs +13 -0
  90. data/sig/ruby_raider/runtime/by_role.rbs +15 -0
  91. data/sig/ruby_raider/runtime/config.rbs +26 -0
  92. data/sig/ruby_raider/runtime/context.rbs +12 -0
  93. data/sig/ruby_raider/runtime/errors.rbs +36 -0
  94. data/sig/ruby_raider/runtime/finders.rbs +26 -0
  95. data/sig/ruby_raider/runtime/locator_converter.rbs +21 -0
  96. data/sig/ruby_raider/runtime/matchers/base.rbs +64 -0
  97. data/sig/ruby_raider/runtime/matchers/element.rbs +78 -0
  98. data/sig/ruby_raider/runtime/matchers/page.rbs +38 -0
  99. data/sig/ruby_raider/runtime/matchers/presence.rbs +21 -0
  100. data/sig/ruby_raider/runtime/matchers/retrying_block.rbs +27 -0
  101. data/sig/ruby_raider/runtime/observer.rbs +46 -0
  102. data/sig/ruby_raider/runtime/role_finder.rbs +18 -0
  103. data/sig/ruby_raider/runtime/selectors.rbs +21 -0
  104. data/sig/ruby_raider/runtime/strict.rbs +35 -0
  105. data/sig/ruby_raider/runtime/wait.rbs +17 -0
  106. data/sig/ruby_raider/runtime.rbs +2 -0
  107. data/sig/scaffolding/project_detector.rbs +0 -4
  108. data/sig/scaffolding/scaffolding.rbs +0 -5
  109. data/sig/utilities/utilities.rbs +2 -1
  110. data/sig/vendor/selenium_webdriver.rbs +71 -0
  111. data/spec/commands/raider_commands_spec.rb +38 -3
  112. data/spec/commands/scaffolding_commands_spec.rb +27 -0
  113. data/spec/commands/utility_commands_spec.rb +53 -0
  114. data/spec/generators/gesture_helper_spec.rb +123 -0
  115. data/spec/generators/network_helper_spec.rb +634 -0
  116. data/spec/generators/screenshot_helper_spec.rb +776 -0
  117. data/spec/generators/template_renderer_spec.rb +0 -4
  118. data/spec/generators/trace_helper_spec.rb +489 -97
  119. data/spec/generators/video_helper_spec.rb +0 -9
  120. data/spec/integration/commands/browser_update_after_creation_spec.rb +0 -8
  121. data/spec/integration/commands/scaffolding_commands_spec.rb +12 -70
  122. data/spec/integration/commands/utility_commands_spec.rb +19 -41
  123. data/spec/integration/content/azure_content_spec.rb +2 -6
  124. data/spec/integration/content/ci_content_spec.rb +1 -55
  125. data/spec/integration/content/common_content_spec.rb +1 -118
  126. data/spec/integration/content/config_content_spec.rb +34 -31
  127. data/spec/integration/content/gemfile_content_spec.rb +7 -72
  128. data/spec/integration/content/helper_content_spec.rb +105 -99
  129. data/spec/integration/content/page_content_spec.rb +85 -87
  130. data/spec/integration/content/readme_content_spec.rb +134 -75
  131. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  132. data/spec/integration/content/rubocop_content_spec.rb +1 -1
  133. data/spec/integration/content/syntax_validation_spec.rb +2 -2
  134. data/spec/integration/content/test_content_spec.rb +23 -147
  135. data/spec/integration/end_to_end_features_spec.rb +1 -43
  136. data/spec/integration/end_to_end_spec.rb +319 -326
  137. data/spec/integration/generators/automation_generator_spec.rb +3 -37
  138. data/spec/integration/generators/axe_addon_spec.rb +2 -35
  139. data/spec/integration/generators/azure_generator_spec.rb +1 -1
  140. data/spec/integration/generators/common_generator_spec.rb +1 -40
  141. data/spec/integration/generators/config_features_spec.rb +1 -24
  142. data/spec/integration/generators/github_generator_spec.rb +0 -12
  143. data/spec/integration/generators/helpers_generator_spec.rb +74 -80
  144. data/spec/integration/generators/invoke_generators_spec.rb +54 -45
  145. data/spec/integration/generators/rspec_generator_spec.rb +0 -6
  146. data/spec/integration/scaffolding_e2e_spec.rb +17 -179
  147. data/spec/integration/spec_helper.rb +4 -6
  148. data/spec/integration/trace_format_checker.rb +11 -2
  149. data/spec/integration/trace_page_server.rb +72 -5
  150. data/spec/menus/menu_generator_spec.rb +14 -39
  151. data/spec/runtime/actionability_spec.rb +207 -0
  152. data/spec/runtime/actionable_spec.rb +125 -0
  153. data/spec/runtime/browser/actionability_script_browser_spec.rb +150 -0
  154. data/spec/runtime/browser/actionable_browser_spec.rb +98 -0
  155. data/spec/runtime/browser/finders_browser_spec.rb +148 -0
  156. data/spec/runtime/browser/matchers_browser_spec.rb +67 -0
  157. data/spec/runtime/browser/retrying_block_browser_spec.rb +37 -0
  158. data/spec/runtime/browser/role_engine_browser_spec.rb +184 -0
  159. data/spec/runtime/browser/strict_browser_spec.rb +38 -0
  160. data/spec/runtime/by_role_spec.rb +52 -0
  161. data/spec/runtime/config_spec.rb +79 -0
  162. data/spec/runtime/context_spec.rb +73 -0
  163. data/spec/runtime/errors_spec.rb +89 -0
  164. data/spec/runtime/finders_spec.rb +165 -0
  165. data/spec/runtime/fixtures/actionability.html +70 -0
  166. data/spec/runtime/fixtures/locators.html +46 -0
  167. data/spec/runtime/fixtures/matchers.html +33 -0
  168. data/spec/runtime/fixtures/roles.html +162 -0
  169. data/spec/runtime/matchers/base_spec.rb +165 -0
  170. data/spec/runtime/matchers/element_spec.rb +277 -0
  171. data/spec/runtime/matchers/page_spec.rb +102 -0
  172. data/spec/runtime/matchers/presence_spec.rb +59 -0
  173. data/spec/runtime/matchers/retrying_block_spec.rb +165 -0
  174. data/spec/runtime/observer_spec.rb +341 -0
  175. data/spec/runtime/role_finder_spec.rb +156 -0
  176. data/spec/runtime/role_query_spec.rb +75 -0
  177. data/spec/runtime/selectors_spec.rb +135 -0
  178. data/spec/runtime/strict_spec.rb +257 -0
  179. data/spec/runtime/support/browser.rb +48 -0
  180. data/spec/runtime/support/fake_bridge.rb +117 -0
  181. data/spec/runtime/wait_spec.rb +71 -0
  182. data/spec/scaffolding/name_normalizer_spec.rb +3 -2
  183. data/spec/scaffolding/scaffold_project_detector_spec.rb +4 -43
  184. data/spec/system/selenium_spec.rb +1 -5
  185. data/spec/system/support/system_test_helper.rb +2 -5
  186. data/spec/utilities/headless_config_spec.rb +0 -7
  187. data/spec/utilities/utilities_spec.rb +24 -0
  188. metadata +113 -48
  189. data/lib/generators/automation/templates/header.tt +0 -9
  190. data/lib/generators/cucumber/cucumber_generator.rb +0 -58
  191. data/lib/generators/cucumber/templates/accessibility_feature.tt +0 -5
  192. data/lib/generators/cucumber/templates/accessibility_steps.tt +0 -17
  193. data/lib/generators/cucumber/templates/cucumber.tt +0 -1
  194. data/lib/generators/cucumber/templates/env.tt +0 -7
  195. data/lib/generators/cucumber/templates/feature.tt +0 -21
  196. data/lib/generators/cucumber/templates/partials/appium_env.tt +0 -26
  197. data/lib/generators/cucumber/templates/partials/driver_world.tt +0 -18
  198. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +0 -20
  199. data/lib/generators/cucumber/templates/partials/selenium_env.tt +0 -120
  200. data/lib/generators/cucumber/templates/partials/watir_env.tt +0 -48
  201. data/lib/generators/cucumber/templates/partials/watir_world.tt +0 -4
  202. data/lib/generators/cucumber/templates/partials/web_steps.tt +0 -28
  203. data/lib/generators/cucumber/templates/steps.tt +0 -5
  204. data/lib/generators/cucumber/templates/user_factory.tt +0 -15
  205. data/lib/generators/cucumber/templates/world.tt +0 -7
  206. data/lib/generators/templates/helpers/browser_helper.tt +0 -31
  207. data/lib/generators/templates/helpers/raider_element.tt +0 -159
  208. data/lib/generators/templates/helpers/wait_helper.tt +0 -611
  209. data/lib/scaffolding/templates/feature.tt +0 -6
  210. data/lib/scaffolding/templates/steps.tt +0 -19
  211. data/sig/generators/cucumber/cucumber_generator.rbs +0 -12
  212. data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +0 -175
  213. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +0 -24
  214. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +0 -37
  215. data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +0 -33
  216. data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +0 -174
  217. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +0 -37
  218. data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +0 -33
  219. data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +0 -49
  220. data/spec/integration/content/cucumber_reporter_content_spec.rb +0 -161
  221. data/spec/integration/generators/cucumber_generator_spec.rb +0 -100
  222. data/spec/integration/helpers/wait_helper_mobile_spec.rb +0 -281
  223. data/spec/integration/helpers/wait_helper_spec.rb +0 -679
  224. data/spec/integration/helpers/wait_helper_support.rb +0 -183
  225. data/spec/system/watir_spec.rb +0 -38
@@ -3,28 +3,28 @@
3
3
  require 'cgi/escape'
4
4
  require 'fileutils'
5
5
  require 'json'
6
+ require 'ruby_raider/runtime'
6
7
  require 'selenium-webdriver'
7
8
  require 'tmpdir'
8
9
  require 'yaml'
9
10
  require 'zlib'
10
11
 
11
- # Records a trace of each test: one step per WaitHelper action and navigation, with
12
+ # Records a trace of each test: one step per element action and navigation, with
12
13
  # a screenshot and the page HTML after each state-changing step. stop(keep: true)
13
14
  # writes it as trace.zip in the raider-trace v1 format, which Raider Desktop and
14
15
  # Hideout open. Any capture error prints one [trace] line and turns tracing off for
15
16
  # the rest of the run; it never raises into a test. Something only left out (a
16
17
  # screenshot, or network on a browser without BiDi) prints at most one notice. TRACE=off skips tracing and
17
18
  # TRACE=on keeps the trace of passing tests too.
18
- # The Recorder follows WaitHelper as its observer. Without helpers/wait_helper.rb a
19
- # trace records only navigation and the failure. Navigation comes from TraceHelper.listener,
19
+ # The Recorder follows Raider.observer. Navigation comes from TraceHelper.listener,
20
20
  # and network and console from passive WebDriver BiDi events (chrome, edge and firefox).
21
21
  module TraceHelper
22
22
  DEFAULTS = { 'enabled' => true, 'max_steps' => 200, 'max_mb' => 20 }.freeze
23
23
  PRODUCER = { name: 'ruby_raider', version: '<%= ruby_raider_version %>' }.freeze
24
24
  # Only these get a screenshot and a snapshot; query actions cost almost nothing.
25
- MEDIA_ACTIONS = %w[navigate click send_keys clear submit select_by hover drag_and_drop scroll_into_view].freeze
26
- # Only these record what was typed or selected, as value (null when secret).
27
- TYPED_ACTIONS = %w[send_keys select_by].freeze
25
+ MEDIA_ACTIONS = %w[navigate click send_keys clear submit].freeze
26
+ # Only these record what was typed, as value (null when secret).
27
+ TYPED_ACTIONS = %w[send_keys].freeze
28
28
  SECRET_ENV_PREFIX = 'RAIDER_SECRET_'
29
29
  # The driver helper sets web_socket_url for these while tracing is on.
30
30
  BIDI_BROWSERS = %w[chrome edge firefox].freeze
@@ -35,6 +35,7 @@ module TraceHelper
35
35
  @warned = false
36
36
  @disable_warned = false
37
37
  @active_recorder = nil
38
+ @flags = Mutex.new
38
39
 
39
40
  class << self
40
41
  attr_accessor :active_recorder
@@ -54,7 +55,7 @@ module TraceHelper
54
55
  # True while tracing is on for a browser that speaks WebDriver BiDi (Edge reports MicrosoftEdge).
55
56
  def bidi?(browser)
56
57
  name = browser.to_s.downcase.delete_prefix('microsoft')
57
- enabled? && BIDI_BROWSERS.any? { |bidi_browser| name.start_with?(bidi_browser) }
58
+ enabled? && ENV['BIDI'].to_s.downcase != 'off' && BIDI_BROWSERS.any? { |bidi_browser| name.start_with?(bidi_browser) }
58
59
  end
59
60
 
60
61
  # Passed to Selenium through the driver helper's fan-out listener when the driver is created.
@@ -69,17 +70,19 @@ module TraceHelper
69
70
  return null_recorder unless enabled?
70
71
 
71
72
  recorder = Recorder.new(driver, screenshot: screenshot, snapshot: snapshot)
72
- WaitHelper.observer = recorder if defined?(WaitHelper)
73
+ Raider.observer = recorder
74
+ # Lazy: the project may have removed network_helper.
75
+ NetworkHelper.observer = MockMarks if defined?(NetworkHelper)
73
76
  recorder
74
77
  rescue StandardError => e
75
78
  disable!("could not create a recorder: #{e.message}")
76
79
  null_recorder
77
80
  end
78
81
 
79
- # Unhooks the recorder from WaitHelper and the navigation listener.
82
+ # Unhooks the recorder from Raider.observer and the navigation listener.
80
83
  def release(recorder)
81
84
  @active_recorder = nil if @active_recorder.equal?(recorder)
82
- WaitHelper.observer = nil if defined?(WaitHelper) && WaitHelper.observer.equal?(recorder)
85
+ Raider.observer = nil if Raider.observer.equal?(recorder)
83
86
  end
84
87
 
85
88
  # The first reason tracing stopped always gets its own [trace] line, even after a notice.
@@ -87,22 +90,33 @@ module TraceHelper
87
90
  @disabled = true
88
91
  release(@active_recorder)
89
92
  detach_observer
90
- return nil if @disable_warned
93
+ return nil unless claim(:@disable_warned)
91
94
 
92
- @disable_warned = true
93
95
  say("#{reason}; tracing disabled for this run")
94
96
  nil
95
97
  end
96
98
 
97
99
  # At most one notice per run, for something left out of a trace while tracing goes on.
98
100
  def warn_once(message)
99
- return nil if @warned
101
+ return nil unless claim(:@warned)
100
102
 
101
- @warned = true
102
103
  say(message)
103
104
  nil
104
105
  end
105
106
 
107
+ # One notice per run for events dropped as malformed, apart from warn_once so neither hides the other.
108
+ def warn_dropped_event(message)
109
+ return nil unless claim(:@dropped_warned)
110
+
111
+ say(message)
112
+ nil
113
+ end
114
+
115
+ # A real number that is neither NaN nor infinite; a duration or a timestamp is built only from these.
116
+ def finite_number?(value)
117
+ value.is_a?(Numeric) && value.real? && value.finite?
118
+ end
119
+
106
120
  # Above 9999 is taken as 9999, silently; zero or less (or not a number) as the default.
107
121
  def max_steps
108
122
  value = config['max_steps'].to_i
@@ -141,7 +155,7 @@ module TraceHelper
141
155
  end
142
156
 
143
157
  def detach_observer
144
- WaitHelper.observer = nil if defined?(WaitHelper) && WaitHelper.observer.is_a?(Recorder)
158
+ Raider.observer = nil if Raider.observer.is_a?(Recorder)
145
159
  end
146
160
 
147
161
  def trace_config
@@ -151,6 +165,15 @@ module TraceHelper
151
165
  {}
152
166
  end
153
167
 
168
+ # Atomically takes a once-only flag; true for the first caller only. Events arrive on websocket threads.
169
+ def claim(flag)
170
+ @flags.synchronize do
171
+ next false if instance_variable_get(flag)
172
+
173
+ instance_variable_set(flag, true)
174
+ end
175
+ end
176
+
154
177
  # $stderr.puts, not Kernel#warn: warn is silenced under -W0.
155
178
  # rubocop:disable Style/StderrPuts
156
179
  def say(message)
@@ -159,8 +182,40 @@ module TraceHelper
159
182
  # rubocop:enable Style/StderrPuts
160
183
  end
161
184
 
162
- # One step of the timeline. parent is the enclosing group Step; the index is given
163
- # when the trace is written, after max_steps and eventually have dropped steps.
185
+ # Where an action was called from, for the observer.
186
+ module Observation
187
+ # helpers/ by its given and its real path (__dir__ is the real one), and the runtime's own directory:
188
+ # the trace, driver and runtime files are never the line that called an action or a navigation.
189
+ HELPER_DIRS = [File.dirname(__FILE__), __dir__, Raider::Observer::LIB_DIR].uniq.map { |dir| File.join(dir, '') }.freeze
190
+
191
+ # The first frame outside helpers/, Ruby and gems, as { file:, line: } (file relative
192
+ # to the working directory when inside it), or nil.
193
+ def self.source
194
+ frame = caller_locations.find { |location| project?(location.absolute_path || location.path) }
195
+ return unless frame
196
+
197
+ { file: relative(frame.absolute_path || frame.path), line: frame.lineno }
198
+ end
199
+
200
+ def self.project?(path)
201
+ return false if path.start_with?('<internal:') || HELPER_DIRS.any? { |dir| path.start_with?(dir) }
202
+
203
+ library_paths.none? { |root| path.start_with?(root) }
204
+ end
205
+
206
+ def self.library_paths
207
+ @library_paths ||= [*RbConfig::CONFIG.values_at('rubylibdir', 'rubyarchdir'), *Gem.path]
208
+ .compact.map { |dir| File.join(dir, '') }
209
+ end
210
+
211
+ def self.relative(path)
212
+ root = File.join(Dir.pwd, '')
213
+ path.start_with?(root) ? path.delete_prefix(root) : path
214
+ end
215
+ end
216
+
217
+ # One step of the timeline. parent is always nil (the v1 group kind stays readable but is no longer
218
+ # produced); the index is given when the trace is written, after max_steps has dropped steps.
164
219
  # element is the format's { tag, label, role }; secret is true, false, or nil while the field is unknown.
165
220
  Step = Struct.new(:seq, :parent, :kind, :action, :locator, :url, :page_title, :started_at, :clock, :duration_ms,
166
221
  :waited_ms, :retries, :status, :error, :source, :rect, :screenshot, :snapshot, :element,
@@ -171,12 +226,6 @@ module TraceHelper
171
226
  self.duration_ms = ((TraceHelper.monotonic_now - clock) * 1000).round
172
227
  end
173
228
 
174
- def within?(group)
175
- step = parent
176
- step = step.parent until step.nil? || step.equal?(group)
177
- !step.nil?
178
- end
179
-
180
229
  def as_json(index, parent_index, media)
181
230
  json = { index: index, parent_index: parent_index, kind: kind, action: action, locator: locator }
182
231
  json[:element] = element if element
@@ -194,11 +243,13 @@ module TraceHelper
194
243
  end
195
244
  end
196
245
 
197
- # Follows a test: WaitHelper reports its actions (the observer protocol) and the
246
+ # Follows a test: the runtime reports element actions to Raider.observer and the
198
247
  # navigation listener calls navigated. Media goes to its own tmpdir until stop.
199
248
  # BiDi events arrive on Selenium's websocket threads, so the event lists are locked.
200
249
  class Recorder
201
250
  SNAPSHOT = :page_source.to_proc
251
+ # Requests awaiting their response, and responses awaiting their request: a page cannot grow either past this.
252
+ MAX_PENDING = 1_000
202
253
 
203
254
  attr_reader :dir, :capabilities
204
255
 
@@ -224,16 +275,30 @@ module TraceHelper
224
275
  discard
225
276
  end
226
277
 
227
- # Returns the step as the token WaitHelper hands back. args: send_keys' keys or select_by's how and what.
228
- # The locator has no label: a page object's element method has returned before the action runs.
229
- def action_started(action:, locator:, within:, source:, args: nil)
278
+ # Returns the step as the token the runtime hands back (nil when not recording, so no ready or finished
279
+ # follows). args: send_keys' keys. The locator has no label: a page object's element method has returned
280
+ # before the action runs.
281
+ def action_started(action:, locator:, within:, args: nil)
230
282
  record('action') do
231
- step = add(kind: 'action', action: action.to_s, locator: trace_locator(locator, within), source: source)
283
+ step = add(kind: 'action', action: action.to_s, locator: trace_locator(locator, within),
284
+ source: TraceHelper::Observation.source)
232
285
  typed(step, Array(args)) if TYPED_ACTIONS.include?(step.action)
233
286
  step
234
287
  end
235
288
  end
236
289
 
290
+ # match_screenshot compared the page with a baseline. The PNG it took is the step's screenshot,
291
+ # so no second screenshot is taken.
292
+ def screenshot_compared(name:, status:, png:, error: nil)
293
+ record('action') do
294
+ step = add(kind: 'action', action: 'match_screenshot', locator: nil, source: navigation_source)
295
+ step.name = name
296
+ step.finish(status, error)
297
+ step.screenshot = media(step, 'screenshot') { [png, 'png'] }
298
+ nil
299
+ end
300
+ end
301
+
237
302
  # The element is ready and the action is about to run: its rect, what it is, and whether it is secret.
238
303
  # Query actions skip it, to stay cheap.
239
304
  def action_ready(step, element:)
@@ -254,49 +319,22 @@ module TraceHelper
254
319
  end
255
320
  end
256
321
 
257
- def group_started(action:, source:)
258
- record('group') do
259
- group = add(kind: 'group', action: action.to_s, locator: nil, source: source)
260
- @groups.push(group)
261
- group
262
- end
263
- end
264
-
265
- # A new pass of eventually: only the last pass's steps are kept, and the
266
- # group's error is the last one an earlier pass raised.
267
- def group_retried(group, error: nil)
268
- record('group') do
269
- dropped, @steps = @steps.partition { |step| step.within?(group) }
270
- dropped.each { |step| delete_media(step) }
271
- group.error = error
272
- nil
273
- end
274
- end
275
-
276
- def group_finished(group, status:, retries:, error:)
277
- record('group') do
278
- @groups.reject! { |open| open.equal?(group) }
279
- group.finish(status, error || group.error)
280
- group.retries = retries
322
+ # A navigation has begun. Its step stays pending until navigated, so the requests the page makes
323
+ # while loading are credited to it. A driver.get that fails never reaches navigated.
324
+ def navigating(url)
325
+ record('navigation') do
326
+ finish_navigation(:failed)
327
+ @navigation = add(kind: 'navigate', action: 'navigate', locator: nil, source: navigation_source)
328
+ @navigation.url = url if url
281
329
  nil
282
330
  end
283
331
  end
284
332
 
285
- # A Cucumber step is a group named by its keyword and text; the actions it runs become its children.
286
- def step_started(name:, source:)
287
- group = group_started(action: :cucumber_step, source: source)
288
- group.name = name if group.is_a?(Step)
289
- group
290
- end
291
-
292
- def step_finished(group, status:, error:)
293
- group_finished(group, status: status, retries: 0, error: error) if group.is_a?(Step)
294
- end
295
-
296
333
  def navigated(url)
297
334
  record('navigation') do
298
- step = add(kind: 'navigate', action: 'navigate', locator: nil, source: navigation_source)
299
- step.url = url
335
+ step = @navigation || add(kind: 'navigate', action: 'navigate', locator: nil, source: navigation_source)
336
+ @navigation = nil
337
+ step.url = url if url
300
338
  step.finish(:passed, nil)
301
339
  capture_step_media(step)
302
340
  nil
@@ -320,6 +358,8 @@ module TraceHelper
320
358
  entry = event(:network, hash)
321
359
  early = @lock.synchronize do
322
360
  @requests[request] = [entry, at]
361
+ @requests.shift while @requests.size > MAX_PENDING
362
+ entry.update(@early_marks.delete(request) || {})
323
363
  @early.delete(request)
324
364
  end
325
365
  network_completed(request, *early) if early
@@ -331,13 +371,32 @@ module TraceHelper
331
371
  def network_completed(request, fields, at = nil)
332
372
  record('network') do
333
373
  entry, started = @lock.synchronize do
334
- @requests.delete(request).tap { |found| @early[request] = [fields, at] unless found }
374
+ @requests.delete(request).tap { |found| remember_early(request, fields, at) unless found }
335
375
  end
336
376
  complete(entry, fields, at, started) if entry
337
377
  nil
338
378
  end
339
379
  end
340
380
 
381
+ # A mocked request, marked by NetworkHelper before it answers. The entry stays pending: its
382
+ # completion still arrives, and its status wins when it has one. A mark that comes before
383
+ # network_started is kept until the entry exists.
384
+ def network_marked(request, fields)
385
+ record('network') do
386
+ fields = fields.transform_keys(&:to_s)
387
+ @lock.synchronize do
388
+ entry = @requests[request]&.first
389
+ if entry
390
+ entry.update(fields)
391
+ else
392
+ @early_marks[request] = fields
393
+ @early_marks.shift while @early_marks.size > MAX_PENDING
394
+ end
395
+ end
396
+ nil
397
+ end
398
+ end
399
+
341
400
  # hash: a console[] entry of the format; step_index is filled in when written.
342
401
  def console_event(hash)
343
402
  record('console') do
@@ -371,6 +430,7 @@ module TraceHelper
371
430
  TraceHelper.release(self)
372
431
  return discard unless keep && TraceHelper.enabled?
373
432
 
433
+ finish_navigation(:failed, test[:error])
374
434
  failure(test[:error])
375
435
  write(test)
376
436
  rescue StandardError => e
@@ -390,11 +450,12 @@ module TraceHelper
390
450
 
391
451
  def reset
392
452
  @steps = []
393
- @groups = []
453
+ @navigation = nil
394
454
  @lock.synchronize do
395
455
  @events = []
396
456
  @requests = {}
397
457
  @early = {}
458
+ @early_marks = {}
398
459
  end
399
460
  @bidi = false
400
461
  @seq = 0
@@ -418,7 +479,7 @@ module TraceHelper
418
479
  end
419
480
 
420
481
  # Runs a hook while recording. A hook that fails turns tracing off instead of
421
- # raising into WaitHelper (which would warn a second time).
482
+ # raising into the runtime (which would warn a second time).
422
483
  def record(what)
423
484
  return nil unless @recording && TraceHelper.enabled?
424
485
 
@@ -428,8 +489,9 @@ module TraceHelper
428
489
  end
429
490
 
430
491
  def add(kind:, action:, locator:, source:)
492
+ finish_navigation(:failed) unless kind == 'navigate'
431
493
  @seq += 1
432
- step = Step.new(seq: @seq, parent: @groups.last, kind: kind, action: action, locator: locator, source: source,
494
+ step = Step.new(seq: @seq, parent: nil, kind: kind, action: action, locator: locator, source: source,
433
495
  url: @page[:url], page_title: @page[:page_title], started_at: TraceHelper.timestamp,
434
496
  clock: TraceHelper.monotonic_now, waited_ms: 0, retries: 0)
435
497
  @steps << step
@@ -437,14 +499,35 @@ module TraceHelper
437
499
  step
438
500
  end
439
501
 
502
+ # A navigation still pending when the next step starts, or at stop, never completed. At stop the test's
503
+ # error goes to it, so failure does not add a second step for the same exception.
504
+ def finish_navigation(status, error = nil)
505
+ return unless @navigation
506
+
507
+ @navigation.finish(status, error)
508
+ @navigation = nil
509
+ end
510
+
440
511
  def drop_oldest_step
441
- delete_media(@steps.shift)
512
+ step = @steps.shift
513
+ @navigation = nil if step.equal?(@navigation)
514
+ delete_media(step)
442
515
  @truncated[:steps] += 1
443
516
  end
444
517
 
518
+ # Called inside the lock.
519
+ def remember_early(request, fields, at)
520
+ @early[request] = [fields, at]
521
+ @early.shift while @early.size > MAX_PENDING
522
+ end
523
+
445
524
  def complete(entry, fields, finished, started)
446
- fields = fields.merge(duration_ms: (finished - started).round) if finished.is_a?(Numeric) && started.is_a?(Numeric)
447
- @lock.synchronize { entry.update(fields.transform_keys(&:to_s)) }
525
+ duration = finished - started if TraceHelper.finite_number?(finished) && TraceHelper.finite_number?(started)
526
+ fields = fields.merge(duration_ms: duration.round) if TraceHelper.finite_number?(duration)
527
+ @lock.synchronize do
528
+ fields = fields.reject { |key, value| key == :status && value.nil? && entry['status'] }
529
+ entry.update(fields.transform_keys(&:to_s))
530
+ end
448
531
  end
449
532
 
450
533
  # Returns the stored entry, which network_completed may still fill in.
@@ -463,12 +546,12 @@ module TraceHelper
463
546
  end
464
547
 
465
548
  def navigation_source
466
- WaitHelper::Observation.source if defined?(WaitHelper::Observation)
549
+ TraceHelper::Observation.source
467
550
  end
468
551
 
469
- # The value as typed: strings as they are, keys such as :enter as {enter}; select_by's what.
552
+ # The value as typed: strings as they are, keys such as :enter as {enter}.
470
553
  def typed(step, args)
471
- shown = step.action == 'select_by' ? args.last.to_s : args.flatten.map { |key| key_name(key) }.join
554
+ shown = args.flatten.map { |key| key_name(key) }.join
472
555
  step.value = shown
473
556
  step.typed_strings = [shown, *args.flatten.grep(String)].reject(&:empty?).uniq
474
557
  step.secret = true if step.typed_strings.any? { |each_value| TraceHelper.secret_value?(each_value) }
@@ -526,7 +609,7 @@ module TraceHelper
526
609
  end
527
610
 
528
611
  # A test that raised outside every kept step gets a last failure step. Steps dropped by
529
- # eventually or max_steps own nothing, so their error still gets a step.
612
+ # max_steps own nothing, so their error still gets a step.
530
613
  def failure(error)
531
614
  return if error.nil? || @steps.any? { |step| step.error.equal?(error) }
532
615
 
@@ -602,9 +685,13 @@ module TraceHelper
602
685
  { name: nil, version: nil }
603
686
  end
604
687
 
688
+ # The inner size: the outer window size includes the browser chrome, so it would not match
689
+ # the rects or the viewport screenshot.
605
690
  def viewport
606
- size = @driver.manage.window.size
607
- { width: size.width, height: size.height }
691
+ width, height = @driver.execute_script('return [window.innerWidth, window.innerHeight]')
692
+ return { width: nil, height: nil } unless width.is_a?(Numeric) && height.is_a?(Numeric)
693
+
694
+ { width: width, height: height }
608
695
  rescue StandardError
609
696
  { width: nil, height: nil }
610
697
  end
@@ -748,6 +835,10 @@ module TraceHelper
748
835
  nil
749
836
  end
750
837
 
838
+ def navigating(_url)
839
+ nil
840
+ end
841
+
751
842
  def navigated(_url)
752
843
  nil
753
844
  end
@@ -764,6 +855,10 @@ module TraceHelper
764
855
  nil
765
856
  end
766
857
 
858
+ def network_marked(_request, _fields)
859
+ nil
860
+ end
861
+
767
862
  def console_event(_hash)
768
863
  nil
769
864
  end
@@ -966,11 +1061,24 @@ module TraceHelper
966
1061
  end
967
1062
  end
968
1063
 
969
- # What comes from the browser beside WaitHelper: navigation from Selenium's event listener,
1064
+ # What comes from the browser beside the element actions: navigation from Selenium's event listener,
970
1065
  # network and console from WebDriver BiDi, and the screenshot.
971
1066
  module TraceHelper
972
1067
  # Selenium calls these after each navigation (through the driver helper's fan-out listener).
973
1068
  class Listener < Selenium::WebDriver::Support::AbstractEventListener
1069
+ # Before, so the requests of the load belong to the navigate step. If the navigation fails, after is never called.
1070
+ def before_navigate_to(url, _driver)
1071
+ navigating(url)
1072
+ end
1073
+
1074
+ def before_navigate_back(_driver)
1075
+ navigating(nil)
1076
+ end
1077
+
1078
+ def before_navigate_forward(_driver)
1079
+ navigating(nil)
1080
+ end
1081
+
974
1082
  def after_navigate_to(url, _driver)
975
1083
  navigated(url)
976
1084
  end
@@ -986,12 +1094,28 @@ module TraceHelper
986
1094
 
987
1095
  private
988
1096
 
1097
+ def navigating(url)
1098
+ TraceHelper.active_recorder&.navigating(url)
1099
+ nil
1100
+ end
1101
+
989
1102
  def navigated(url)
990
1103
  TraceHelper.active_recorder&.navigated(url)
991
1104
  nil
992
1105
  end
993
1106
  end
994
1107
 
1108
+ # NetworkHelper's observer: marks the trace entry of a request it answers without the network.
1109
+ module MockMarks
1110
+ def self.request_mocked(request_id:, redirect_count:, outcome:, status: nil)
1111
+ fields = { mocked: outcome }
1112
+ fields[:status] = status if status
1113
+ TraceHelper.active_recorder&.network_marked([request_id, redirect_count], fields)
1114
+ rescue StandardError => e
1115
+ TraceHelper.warn_dropped_event("dropped a mocked-request mark: #{e.class}")
1116
+ end
1117
+ end
1118
+
995
1119
  # Passive BiDi: one session.subscribe per driver, never network.addIntercept, which would
996
1120
  # pause every request. Events go to the recorder last attached to that driver. Only the
997
1121
  # method, URL, status, MIME type, timing, size and error text are kept: no headers, no bodies.
@@ -1015,31 +1139,34 @@ module TraceHelper
1015
1139
  def self.subscribe(bidi)
1016
1140
  bidi.session.subscribe(HANDLERS.keys)
1017
1141
  link = Link.new
1018
- HANDLERS.each { |event, handler| bidi.add_callback(event) { |params| deliver(link, handler, params) } }
1142
+ HANDLERS.each { |event, handler| bidi.add_callback(event) { |params| deliver(link, event, handler, params) } }
1019
1143
  link
1020
1144
  end
1021
1145
 
1022
- # On a websocket thread, which swallows errors: a failure is reported here.
1023
- def self.deliver(link, handler, params)
1146
+ # On a websocket thread, which swallows errors. One event we cannot read is dropped with a notice;
1147
+ # tracing carries on, since the next one is usually fine.
1148
+ def self.deliver(link, event, handler, params)
1024
1149
  recorder = link.recorder
1025
1150
  send(handler, recorder, params) if recorder
1026
1151
  rescue StandardError => e
1027
- TraceHelper.disable!("network and console capture failed: #{e.message}")
1152
+ TraceHelper.warn_dropped_event("dropped a malformed BiDi #{event} event: #{e.class}")
1028
1153
  end
1029
1154
 
1030
1155
  def self.request_sent(recorder, params)
1031
- request = params['request'] || {}
1032
- recorder.network_started(request['request'],
1033
- { method: request['method'], url: request['url'], status: nil, mime_type: nil,
1156
+ key = key(params)
1157
+ request = params['request']
1158
+ recorder.network_started(key,
1159
+ { method: scalar(request['method']), url: scalar(request['url']), status: nil, mime_type: nil,
1034
1160
  started_at: time(params['timestamp']), duration_ms: nil, bytes: nil, error: nil },
1035
1161
  at: params['timestamp'])
1036
1162
  end
1037
1163
 
1038
1164
  def self.response_completed(recorder, params)
1039
- response = params['response'] || {}
1040
- recorder.network_completed(params.dig('request', 'request'),
1041
- { status: response['status'], mime_type: response['mimeType'],
1042
- bytes: response['bytesReceived'] || response['encodedDataLength'] },
1165
+ key = key(params)
1166
+ response = params['response'].is_a?(Hash) ? params['response'] : {}
1167
+ recorder.network_completed(key,
1168
+ { status: scalar(response['status']), mime_type: scalar(response['mimeType']),
1169
+ bytes: scalar(response['bytesReceived'] || response['encodedDataLength']) },
1043
1170
  finished_at(params))
1044
1171
  end
1045
1172
 
@@ -1048,17 +1175,35 @@ module TraceHelper
1048
1175
  def self.finished_at(params)
1049
1176
  stamp = params['timestamp']
1050
1177
  origin, response_end = params.dig('request', 'timings')&.values_at('timeOrigin', 'responseEnd')
1051
- return stamp unless origin.is_a?(Numeric) && response_end.is_a?(Numeric) && response_end.positive?
1178
+ return stamp unless TraceHelper.finite_number?(origin) && TraceHelper.finite_number?(response_end) && response_end.positive?
1052
1179
 
1053
- [stamp, origin + response_end].grep(Numeric).max
1180
+ [stamp, origin + response_end].select { |ms| TraceHelper.finite_number?(ms) }.max
1054
1181
  end
1055
1182
 
1056
1183
  def self.fetch_error(recorder, params)
1057
- recorder.network_completed(params.dig('request', 'request'), { error: params['errorText'] }, params['timestamp'])
1184
+ recorder.network_completed(key(params), { error: scalar(params['errorText']) }, params['timestamp'])
1185
+ end
1186
+
1187
+ # A redirect keeps the request id and counts its hops, so the id alone would merge the hops.
1188
+ # An event without an object request that has a string id is malformed: it raises, so deliver drops it.
1189
+ def self.key(params)
1190
+ request = params['request']
1191
+ raise ArgumentError, 'request is not an object with a string id' unless request.is_a?(Hash) && request['request'].is_a?(String)
1192
+
1193
+ hop = params['redirectCount']
1194
+ [request['request'], hop.is_a?(Integer) ? hop : 0]
1195
+ end
1196
+
1197
+ # Only text, finite numbers, booleans and nil are kept from an event; a list or object becomes nil.
1198
+ def self.scalar(value)
1199
+ case value
1200
+ when String, true, false, nil then value
1201
+ when Numeric then value if TraceHelper.finite_number?(value)
1202
+ end
1058
1203
  end
1059
1204
 
1060
1205
  def self.log_entry(recorder, params)
1061
- recorder.console_event(level: params['level'], text: params['text'], source: location(params),
1206
+ recorder.console_event(level: scalar(params['level']), text: scalar(params['text']), source: location(params),
1062
1207
  at: time(params['timestamp']))
1063
1208
  end
1064
1209
 
@@ -1070,7 +1215,7 @@ module TraceHelper
1070
1215
 
1071
1216
  # BiDi timestamps are epoch milliseconds.
1072
1217
  def self.time(milliseconds)
1073
- return TraceHelper.timestamp unless milliseconds.is_a?(Numeric)
1218
+ return TraceHelper.timestamp unless TraceHelper.finite_number?(milliseconds)
1074
1219
 
1075
1220
  TraceHelper.timestamp(Time.at(Rational(milliseconds.to_i, 1000)))
1076
1221
  end
@@ -69,7 +69,7 @@ module VideoHelper
69
69
  config['enabled'] != false && ENV['VIDEO'] != 'off' && !@disabled
70
70
  end
71
71
 
72
- def recorder_for(driver_or_browser)
72
+ def recorder_for(driver)
73
73
  <%- if mobile? -%>
74
74
  return NullRecorder.new unless enabled?
75
75
  <%- else -%>
@@ -77,9 +77,9 @@ module VideoHelper
77
77
  <%- end -%>
78
78
 
79
79
  <%- if mobile? -%>
80
- AppiumRecorder.new(driver_or_browser)
80
+ AppiumRecorder.new(driver)
81
81
  <%- else -%>
82
- FrameRecorder.new(selenium_driver(driver_or_browser))
82
+ FrameRecorder.new(driver)
83
83
  <%- end -%>
84
84
  rescue StandardError => e
85
85
  disable!("could not create a recorder: #{e.message}")
@@ -186,11 +186,6 @@ module VideoHelper
186
186
  # rubocop:enable Style/StderrPuts
187
187
  <%- unless mobile? -%>
188
188
 
189
- # Watir::Browser wraps the Selenium driver.
190
- def selenium_driver(driver_or_browser)
191
- driver_or_browser.respond_to?(:wd) ? driver_or_browser.wd : driver_or_browser
192
- end
193
-
194
189
  # Decided once per process, at the first listener/recorder_for call, so a run
195
190
  # that can never get an ffmpeg warns at the start and pays no capture cost.
196
191
  def ffmpeg_obtainable?