ruby_raider 3.12.1 → 3.13.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/lib/commands/utility_commands.rb +16 -0
  4. data/lib/generators/cucumber/templates/partials/selenium_env.tt +65 -1
  5. data/lib/generators/generator.rb +10 -1
  6. data/lib/generators/helper_generator.rb +7 -0
  7. data/lib/generators/templates/common/gemfile.tt +1 -1
  8. data/lib/generators/templates/common/partials/web_config.tt +7 -0
  9. data/lib/generators/templates/common/read_me.tt +45 -0
  10. data/lib/generators/templates/helpers/driver_helper.tt +22 -0
  11. data/lib/generators/templates/helpers/partials/selenium_driver.tt +11 -1
  12. data/lib/generators/templates/helpers/raider_element.tt +4 -4
  13. data/lib/generators/templates/helpers/spec_helper.tt +19 -0
  14. data/lib/generators/templates/helpers/trace_helper.tt +1097 -0
  15. data/lib/generators/templates/helpers/wait_helper.tt +145 -4
  16. data/lib/utilities/utilities.rb +7 -0
  17. data/lib/version +1 -1
  18. data/ruby_raider.gemspec +1 -0
  19. data/sig/commands/utility_commands.rbs +2 -0
  20. data/sig/generators/generator.rbs +4 -0
  21. data/sig/generators/helper_generator.rbs +1 -0
  22. data/sig/utilities/utilities.rbs +1 -0
  23. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +11 -2
  24. data/spec/generators/trace_helper_spec.rb +945 -0
  25. data/spec/integration/content/config_content_spec.rb +20 -0
  26. data/spec/integration/content/cucumber_reporter_content_spec.rb +51 -0
  27. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  28. data/spec/integration/content/helper_content_spec.rb +60 -1
  29. data/spec/integration/content/readme_content_spec.rb +46 -0
  30. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  31. data/spec/integration/end_to_end_spec.rb +392 -29
  32. data/spec/integration/generators/helpers_generator_spec.rb +26 -0
  33. data/spec/integration/helpers/wait_helper_spec.rb +183 -0
  34. data/spec/integration/trace_format_checker.rb +141 -0
  35. data/spec/integration/trace_page_server.rb +80 -0
  36. data/spec/utilities/trace_command_spec.rb +66 -0
  37. metadata +27 -2
@@ -28,6 +28,29 @@ module WaitHelper
28
28
  CONFIG_FILE = 'config/config.yml'
29
29
  DEFAULT_TIMEOUT = 10
30
30
 
31
+ class << self
32
+ # Follows every action and eventually when set (the trace helper does). Optional:
33
+ # nothing is called on it while nil, and one that raises is dropped.
34
+ attr_accessor :observer
35
+
36
+ # Calls the observer's hook, or returns nil without one. A broken observer must
37
+ # never fail a test: it is warned about once and removed.
38
+ # A block builds the details, so computing them is covered by the same rescue.
39
+ # $stderr.puts, not Kernel#warn: warn is silenced under -W0.
40
+ # rubocop:disable Style/StderrPuts
41
+ def report(hook, *args, **details)
42
+ current = observer
43
+ return unless current
44
+
45
+ details = yield if block_given?
46
+ current.public_send(hook, *args, **details)
47
+ rescue StandardError => e
48
+ $stderr.puts("[trace] observer stopped: #{e.class}: #{e.message}")
49
+ self.observer = nil
50
+ end
51
+ # rubocop:enable Style/StderrPuts
52
+ end
53
+
31
54
  def self.default_timeout
32
55
  @default_timeout ||= begin
33
56
  config = File.exist?(CONFIG_FILE) ? YAML.load_file(CONFIG_FILE) : nil
@@ -128,6 +151,9 @@ module WaitHelper
128
151
 
129
152
  attr_reader :hint
130
153
 
154
+ # Selenium's aliases for the locator strategies, as the trace names them.
155
+ STRATEGIES = { css_selector: 'css', class: 'class_name', link: 'link_text', partial_link: 'partial_link_text' }.freeze
156
+
131
157
  def self.for(driver, locator, by, scope: driver)
132
158
  raise ArgumentError, 'pass the locator in braces or as keywords, not both' if locator && !by.empty?
133
159
 
@@ -204,6 +230,21 @@ module WaitHelper
204
230
  @scope.lost if @scope_stale
205
231
  end
206
232
 
233
+ # { strategy:, selector: } for the observer, or nil when this locates nothing.
234
+ def trace_locator
235
+ return unless @locator
236
+
237
+ strategy, selector = @locator.first
238
+ { strategy: STRATEGIES.fetch(strategy) { strategy.to_s }, selector: selector }
239
+ end
240
+
241
+ # The locators this one is searched inside, outermost first.
242
+ def trace_within
243
+ return [] unless @scope.is_a?(Lookup)
244
+
245
+ [*@scope.trace_within, @scope.trace_locator].compact
246
+ end
247
+
207
248
  def describe
208
249
  return 'the element' unless @locator
209
250
 
@@ -289,13 +330,66 @@ module WaitHelper
289
330
  # target: a second Lookup (the drop target) that must be visible in the same poll.
290
331
  # between: called after each poll that was not ready (scroll_to scrolls there); once it
291
332
  # returns false the wait stops, and the timeout message ends with its to_s.
292
- def perform_action(action, locator, checks, target: nil, between: nil)
333
+ # args: what the action acts with (send_keys' keys, select_by's how and what), for the observer.
334
+ def perform_action(action, locator, checks, target: nil, between: nil, args: nil, &block)
293
335
  lookup = locator.is_a?(Lookup) ? locator : Lookup.for(@driver, locator, {})
336
+ return poll(action, lookup, checks, target, between, nil, &block) unless WaitHelper.observer
337
+
338
+ observed(action, lookup, args) { |run| poll(action, lookup, checks, target, between, run, &block) }
339
+ end
340
+
341
+ # Retries the block until it passes. Calls inside it share its deadline (see deadline_and_wait).
342
+ def eventually(&block)
343
+ return retry_block(nil, &block) unless WaitHelper.observer
344
+
345
+ token = WaitHelper.report(:group_started) { { action: :eventually, source: Observation.source } }
346
+ run = { retries: 0, token: token }
347
+ begin
348
+ result = retry_block(run, &block)
349
+ WaitHelper.report(:group_finished, token, status: :passed, retries: run[:retries], error: nil) if token
350
+ result
351
+ rescue Exception => e # rubocop:disable Lint/RescueException
352
+ WaitHelper.report(:group_finished, token, status: :failed, retries: run[:retries], error: e) if token
353
+ raise
354
+ end
355
+ end
356
+
357
+ private
358
+
359
+ # Tells the observer an action started, when its element is ready and how it ended. The
360
+ # block gets a run hash that poll fills with the retries it took and the ready element.
361
+ def observed(action, lookup, args)
362
+ run = { retries: 0, element: nil, token: nil }
363
+ token = run[:token] = WaitHelper.report(:action_started) do
364
+ { action: action, locator: lookup.trace_locator, within: lookup.trace_within,
365
+ source: Observation.source, args: args }
366
+ end
367
+ started = now
368
+ begin
369
+ result = yield(run)
370
+ finished(token, :passed, started, run, nil)
371
+ result
372
+ rescue Exception => e # rubocop:disable Lint/RescueException
373
+ finished(token, :failed, started, run, e)
374
+ raise
375
+ end
376
+ end
377
+
378
+ def finished(token, status, started, run, error)
379
+ return unless token
380
+
381
+ WaitHelper.report(:action_finished, token, status: status, waited_ms: ((now - started) * 1000).round,
382
+ retries: run[:retries], error: error, element: run[:element])
383
+ end
384
+
385
+ # run: where to count retries and keep the ready element, or nil when nobody observes.
386
+ def poll(action, lookup, checks, target, between, run)
294
387
  deadline, waited = deadline_and_wait
295
388
  loop do
296
389
  seen = catch(:not_ready) do
297
390
  elements = [ready(lookup, checks)]
298
391
  elements << ready(target, TARGET_CHECKS, 'drop target ') if target
392
+ ready_to_act(run, elements.first) if run
299
393
  return yield(*elements)
300
394
  rescue *RETRIED => e
301
395
  retry_reason(lookup, e, action)
@@ -307,12 +401,19 @@ module WaitHelper
307
401
  raise TimeoutError, timeout_message(lookup, checks, seen, [waited, waited + now - deadline].min), backtrace
308
402
  end
309
403
 
404
+ run[:retries] += 1 if run
310
405
  pause(deadline)
311
406
  end
312
407
  end
313
408
 
314
- # Retries the block until it passes. Calls inside it share its deadline (see deadline_and_wait).
315
- def eventually
409
+ # Keeps the ready element and tells the observer, just before the action runs on it.
410
+ def ready_to_act(run, element)
411
+ run[:element] = element
412
+ token = run[:token]
413
+ WaitHelper.report(:action_ready, token, element: element) if token
414
+ end
415
+
416
+ def retry_block(run)
316
417
  enclosing = Thread.current[DEADLINE_KEY]
317
418
  require 'rspec/expectations'
318
419
  retried = [RSpec::Expectations::ExpectationNotMetError, Selenium::WebDriver::Error::NoSuchElementError,
@@ -324,13 +425,21 @@ module WaitHelper
324
425
  rescue *retried => e
325
426
  raise e.exception("(still failing after #{seconds(waited)}s)\n#{e.message}") if now >= deadline
326
427
 
428
+ retried(run, e)
327
429
  pause(deadline)
328
430
  end
329
431
  ensure
330
432
  Thread.current[DEADLINE_KEY] = enclosing
331
433
  end
332
434
 
333
- private
435
+ # Counts a failed pass of eventually, and tells the observer a new pass starts.
436
+ def retried(run, error)
437
+ return unless run
438
+
439
+ run[:retries] += 1
440
+ token = run[:token]
441
+ WaitHelper.report(:group_retried, token, error: error) if token
442
+ end
334
443
 
335
444
  # Inside eventually, a call without timeout: waits for what is left of eventually's
336
445
  # deadline; one with timeout: waits that long per attempt, never past eventually's deadline.
@@ -405,6 +514,38 @@ module WaitHelper
405
514
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
406
515
  end
407
516
  end
517
+
518
+ # Where an action was called from, for the observer.
519
+ module Observation
520
+ # helpers/ by its given and its real path (__dir__ is the real one): the wait, trace and
521
+ # driver helpers are never the line that called an action or a navigation.
522
+ HELPER_DIRS = [File.dirname(__FILE__), __dir__].uniq.map { |dir| File.join(dir, '') }.freeze
523
+
524
+ # The first frame outside helpers/, Ruby and gems, as { file:, line: } (file relative
525
+ # to the working directory when inside it), or nil.
526
+ def self.source
527
+ frame = caller_locations.find { |location| project?(location.absolute_path || location.path) }
528
+ return unless frame
529
+
530
+ { file: relative(frame.absolute_path || frame.path), line: frame.lineno }
531
+ end
532
+
533
+ def self.project?(path)
534
+ return false if path.start_with?('<internal:') || HELPER_DIRS.any? { |dir| path.start_with?(dir) }
535
+
536
+ library_paths.none? { |root| path.start_with?(root) }
537
+ end
538
+
539
+ def self.library_paths
540
+ @library_paths ||= [*RbConfig::CONFIG.values_at('rubylibdir', 'rubyarchdir'), *Gem.path]
541
+ .compact.map { |dir| File.join(dir, '') }
542
+ end
543
+
544
+ def self.relative(path)
545
+ root = File.join(Dir.pwd, '')
546
+ path.start_with?(root) ? path.delete_prefix(root) : path
547
+ end
548
+ end
408
549
  <%- if mobile? -%>
409
550
 
410
551
  # A swipe or scroll through the platform's own mobile: command, as appium_lib issues them.
@@ -75,6 +75,13 @@ module Utilities
75
75
  overwrite_yaml
76
76
  end
77
77
 
78
+ def trace_enabled=(enabled)
79
+ reload_config
80
+ trace = @config['trace']
81
+ @config['trace'] = (trace.is_a?(Hash) ? trace : {}).merge('enabled' => enabled)
82
+ overwrite_yaml
83
+ end
84
+
78
85
  # Set multiple config keys in a single YAML write.
79
86
  # Usage: Utilities.batch_update(browser: 'chrome', timeout: 30)
80
87
  def batch_update(**settings)
data/lib/version CHANGED
@@ -1 +1 @@
1
- 3.12.1
1
+ 3.13.0
data/ruby_raider.gemspec CHANGED
@@ -21,6 +21,7 @@ Gem::Specification.new do |s|
21
21
  s.add_development_dependency 'rubocop-performance', '~> 1.15.0'
22
22
  s.add_development_dependency 'rubocop-rspec', '~> 2.9.0'
23
23
  s.add_development_dependency 'ruby_audit', '~> 3.1'
24
+ s.add_development_dependency 'rubyzip', '>= 2.3', '< 4'
24
25
  s.add_development_dependency 'selenium-webdriver', '~> 4.0'
25
26
  s.add_development_dependency 'steep', '~> 1.9'
26
27
 
@@ -14,4 +14,6 @@ class UtilityCommands < Thor
14
14
  def video_install: () -> void
15
15
  def video_status: () -> void
16
16
  def video_toggle: (bool enabled) -> void
17
+ def trace: (String action) -> void
18
+ def trace_toggle: (bool enabled) -> void
17
19
  end
@@ -20,6 +20,7 @@ class Generator < Thor::Group
20
20
  def selenium?: () -> bool
21
21
  def watir?: () -> bool
22
22
  def wait_helper?: () -> bool
23
+ def trace_helper?: () -> bool
23
24
  def examples?: () -> bool
24
25
  def web?: () -> bool
25
26
  def selenium_based?: () -> bool
@@ -27,6 +28,9 @@ class Generator < Thor::Group
27
28
  # Add-on predicates
28
29
  def axe_addon?: () -> bool
29
30
 
31
+ def ruby_raider_version: () -> String
32
+ def ruby_raider_version_requirement: () -> String
33
+
30
34
  private
31
35
 
32
36
  def options_after_name: () -> Array[untyped]
@@ -8,4 +8,5 @@ class HelpersGenerator < Generator
8
8
  def generate_spec_helper: () -> void
9
9
  def generate_driver_helper: () -> void
10
10
  def generate_appium_helper: () -> void
11
+ def generate_trace_helper: () -> void
11
12
  end
@@ -14,6 +14,7 @@ module Utilities
14
14
  def self?.browser_options=: (Array[String] opts) -> void
15
15
  def self?.delete_browser_options: () -> void
16
16
  def self?.video_enabled=: (bool enabled) -> void
17
+ def self?.trace_enabled=: (bool enabled) -> void
17
18
  def self?.batch_update: (**untyped settings) -> void
18
19
  def self?.run: (?String? opts) -> void
19
20
  def self?.parallel_run: (?String? opts, ?untyped? _settings) -> void
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Minimal stand-in for selenium-webdriver, which is not in ruby_raider's bundle.
4
- # spec/generators/video_helper_spec.rb puts this directory on $LOAD_PATH so the
5
- # rendered video_helper.rb can `require 'selenium-webdriver'`.
4
+ # spec/generators/video_helper_spec.rb and trace_helper_spec.rb put this directory on
5
+ # $LOAD_PATH so the rendered helpers can `require 'selenium-webdriver'`. The listener
6
+ # lists every callback of the real Support::AbstractEventListener (4.52).
6
7
  module Selenium
7
8
  module WebDriver
8
9
  module Error
@@ -14,6 +15,10 @@ module Selenium
14
15
  class AbstractEventListener
15
16
  def before_navigate_to(url, driver) end
16
17
  def after_navigate_to(url, driver) end
18
+ def before_navigate_back(driver) end
19
+ def after_navigate_back(driver) end
20
+ def before_navigate_forward(driver) end
21
+ def after_navigate_forward(driver) end
17
22
  def before_find(by, what, driver) end
18
23
  def after_find(by, what, driver) end
19
24
  def before_click(element, driver) end
@@ -22,6 +27,10 @@ module Selenium
22
27
  def after_change_value_of(element, driver) end
23
28
  def before_execute_script(script, driver) end
24
29
  def after_execute_script(script, driver) end
30
+ def before_quit(driver) end
31
+ def after_quit(driver) end
32
+ def before_close(driver) end
33
+ def after_close(driver) end
25
34
  end
26
35
  end
27
36
  end