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
@@ -0,0 +1,490 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chunky_png'
4
+ require 'fileutils'
5
+ require 'set'
6
+ require 'rspec/expectations'
7
+ require 'ruby_raider/runtime'
8
+ require 'yaml'
9
+
10
+ # Visual regression for the browser: match_screenshot('home') compares the page (or an element)
11
+ # with a baseline PNG in screenshots/baselines/<browser>/ and fails the test on a difference.
12
+ # Tolerances live in config/config.yml under screenshots:. SCREENSHOTS=update rewrites baselines.
13
+ # Not wanted? Delete this file and the lines that load or include ScreenshotHelper.
14
+ module ScreenshotHelper
15
+ CONFIG_PATH = File.join('config', 'config.yml')
16
+ DEFAULTS = { threshold: 0.2, max_diff_pixels: 0, max_diff_ratio: nil }.freeze
17
+ # Told apart from nil: an explicit max_diff_ratio: nil switches the configured ratio off.
18
+ UNSET = Object.new.freeze
19
+ VALID = {
20
+ threshold: ->(value) { value.is_a?(Numeric) && value.between?(0, 1) },
21
+ max_diff_pixels: ->(value) { value.is_a?(Integer) && value >= 0 },
22
+ max_diff_ratio: ->(value) { value.nil? || (value.is_a?(Numeric) && value.between?(0, 1)) }
23
+ }.freeze
24
+ BROWSER_FOLDER = /\A[a-z0-9_-]+\z/.freeze
25
+
26
+ @names_used = Set.new
27
+ @warned = Set.new
28
+
29
+ class << self
30
+ # The names compared in this test, so two comparisons never share a baseline.
31
+ attr_reader :names_used
32
+
33
+ def reset
34
+ names_used.clear
35
+ end
36
+
37
+ # Case-insensitive: Home and home are the same file on a case-insensitive disk.
38
+ def claim(name)
39
+ raise ArgumentError, "screenshot name '#{name}' was already used in this test" unless names_used.add?(name.downcase)
40
+ end
41
+
42
+ # One warning per setting and value for the whole run.
43
+ # rubocop:disable Style/StderrPuts
44
+ def warn_once(key, value)
45
+ return unless @warned.add?([key, value])
46
+
47
+ $stderr.puts("[screenshots] ignoring #{key}: #{value.inspect} in config/config.yml " \
48
+ "(using #{DEFAULTS[key].inspect}); see the README for the allowed values")
49
+ end
50
+ # rubocop:enable Style/StderrPuts
51
+ end
52
+
53
+ # Raised when a screenshot differs from its baseline; counts as a failed expectation.
54
+ class Mismatch < RSpec::Expectations::ExpectationNotMetError; end
55
+
56
+ # The screenshots: block of config/config.yml over the defaults.
57
+ def self.config
58
+ settings = DEFAULTS.dup
59
+ file_settings.each do |key, value|
60
+ next unless settings.key?(key)
61
+
62
+ VALID.fetch(key).call(value) ? settings[key] = value : warn_once(key, value)
63
+ end
64
+ settings
65
+ end
66
+
67
+ def self.file_settings
68
+ block = read_config['screenshots']
69
+ block.is_a?(Hash) ? block.transform_keys(&:to_sym) : {}
70
+ end
71
+
72
+ def self.read_config
73
+ return {} unless File.exist?(CONFIG_PATH)
74
+
75
+ loaded = YAML.safe_load(File.read(CONFIG_PATH), permitted_classes: [Symbol])
76
+ loaded.is_a?(Hash) ? loaded : {}
77
+ end
78
+
79
+ # The baseline folder: the configured browser, lower case, without a microsoft prefix (MicrosoftEdge is edge).
80
+ def self.browser
81
+ name = read_config['browser'].to_s.downcase.delete_prefix('microsoft')
82
+ name.match?(BROWSER_FOLDER) ? name : 'chrome'
83
+ end
84
+
85
+ def self.update?
86
+ ENV['SCREENSHOTS'].to_s.downcase == 'update'
87
+ end
88
+
89
+ # The reporter's CI detection when it is loaded, and CI in the environment.
90
+ def self.ci?
91
+ detected = defined?(RaiderReporter::CiDetector) ? RaiderReporter::CiDetector.new.ci : nil
92
+ !detected.nil? || !ENV['CI'].to_s.empty?
93
+ rescue StandardError
94
+ !ENV['CI'].to_s.empty?
95
+ end
96
+
97
+ # What a comparison comes to: :passed, :failed, :created, :updated or :missing_on_ci.
98
+ def self.decide(baseline_exists:, matched:)
99
+ return missing_status unless baseline_exists
100
+ return :passed if matched
101
+
102
+ update? ? :updated : :failed
103
+ end
104
+
105
+ def self.missing_status
106
+ ci? && !update? ? :missing_on_ci : :created
107
+ end
108
+
109
+ # Pixel comparison of two decoded PNGs and the picture of what differs. Internal.
110
+ module Diff
111
+ class Unreadable < StandardError; end
112
+
113
+ Result = Struct.new(:diff_pixels, :diff_ratio, :expected_size, :actual_size) do
114
+ def size_changed?
115
+ expected_size != actual_size
116
+ end
117
+ end
118
+
119
+ WHITE = ChunkyPNG::Color::WHITE
120
+ RED = ChunkyPNG::Color.rgb(255, 0, 0)
121
+ # pixelmatch: the largest possible YIQ delta; a pixel differs above this times threshold squared.
122
+ MAX_DELTA = 35_215.0
123
+ FADE = 0.1
124
+
125
+ def self.decode(png)
126
+ ChunkyPNG::Image.from_blob(png)
127
+ rescue StandardError => e
128
+ raise Unreadable, e.message
129
+ end
130
+
131
+ def self.size_of(image)
132
+ [image.width, image.height]
133
+ end
134
+
135
+ # How many pixels differ. Equal pixels are skipped, so only the changed ones cost a colour comparison.
136
+ def self.count(expected, actual, threshold:)
137
+ sizes = [size_of(expected), size_of(actual)]
138
+ return Result.new(0, 0.0, *sizes) unless sizes.first == sizes.last
139
+
140
+ limit = MAX_DELTA * (threshold**2)
141
+ differing = 0
142
+ expected.pixels.each_with_index do |pixel, index|
143
+ other = actual.pixels[index]
144
+ differing += 1 if pixel != other && delta(pixel, other) > limit
145
+ end
146
+ Result.new(differing, differing.fdiv(expected.pixels.length), *sizes)
147
+ end
148
+
149
+ # The picture of a difference as PNG bytes: both images side by side after a size change,
150
+ # otherwise the differing pixels in red over a faded copy of the expected image.
151
+ def self.image(expected, actual, threshold:)
152
+ return side_by_side(expected, actual).to_blob unless size_of(expected) == size_of(actual)
153
+
154
+ limit = MAX_DELTA * (threshold**2)
155
+ canvas = ChunkyPNG::Image.new(expected.width, expected.height, WHITE)
156
+ expected.pixels.each_with_index do |pixel, index|
157
+ other = actual.pixels[index]
158
+ canvas.pixels[index] = pixel != other && delta(pixel, other) > limit ? RED : faded(pixel)
159
+ end
160
+ canvas.to_blob
161
+ end
162
+
163
+ def self.side_by_side(expected, actual)
164
+ canvas = ChunkyPNG::Image.new(expected.width + actual.width, [expected.height, actual.height].max, WHITE)
165
+ canvas.compose!(expected, 0, 0)
166
+ canvas.compose!(actual, expected.width, 0)
167
+ end
168
+
169
+ def self.delta(first, second)
170
+ dy, di, dq = yiq(first).zip(yiq(second)).map { |one, two| one - two }
171
+ (0.5053 * dy * dy) + (0.299 * di * di) + (0.1957 * dq * dq)
172
+ end
173
+
174
+ def self.yiq(pixel)
175
+ red, green, blue = blended(pixel)
176
+ [(red * 0.29889531) + (green * 0.58662247) + (blue * 0.11448223),
177
+ (red * 0.59597799) - (green * 0.27417610) - (blue * 0.32180189),
178
+ (red * 0.21147017) - (green * 0.52261711) + (blue * 0.31114694)]
179
+ end
180
+
181
+ # Semi-transparent pixels count as they look over a white page.
182
+ def self.blended(pixel)
183
+ alpha = ChunkyPNG::Color.a(pixel)
184
+ [ChunkyPNG::Color.r(pixel), ChunkyPNG::Color.g(pixel), ChunkyPNG::Color.b(pixel)].map do |channel|
185
+ 255 + ((channel - 255) * alpha / 255.0)
186
+ end
187
+ end
188
+
189
+ def self.faded(pixel)
190
+ luma = yiq(pixel).first
191
+ grey = (255 + ((luma - 255) * FADE)).round.clamp(0, 255)
192
+ ChunkyPNG::Color.rgb(grey, grey, grey)
193
+ end
194
+ end
195
+
196
+ # Where a baseline lives and how it is written. Internal.
197
+ class Baseline
198
+ NAME_PATTERN = %r{\A[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*\z}.freeze
199
+ MAX_NAME = 100
200
+
201
+ def self.validate_name!(name)
202
+ return if name.is_a?(String) && name.length <= MAX_NAME && name.match?(NAME_PATTERN)
203
+
204
+ raise ArgumentError, 'screenshot name must be letters, digits, - and _ with / for folders, ' \
205
+ "at most #{MAX_NAME} characters; got #{name.inspect}"
206
+ end
207
+
208
+ def initialize(name, browser:, root: Dir.pwd)
209
+ self.class.validate_name!(name)
210
+ @path = "screenshots/baselines/#{browser}/#{name}.png"
211
+ @root = root
212
+ end
213
+
214
+ attr_reader :path
215
+
216
+ def exist?
217
+ File.file?(full_path)
218
+ end
219
+
220
+ def read
221
+ File.binread(full_path)
222
+ end
223
+
224
+ # Through a temp file and a rename, so two processes never leave half a PNG.
225
+ def write(png)
226
+ FileUtils.mkdir_p(File.dirname(full_path))
227
+ temp = "#{full_path}.#{Process.pid}.#{rand(36**6).to_s(36)}.tmp"
228
+ File.binwrite(temp, png)
229
+ File.rename(temp, full_path)
230
+ ensure
231
+ FileUtils.rm_f(temp) if temp
232
+ end
233
+
234
+ private
235
+
236
+ def full_path
237
+ File.join(@root, @path)
238
+ end
239
+ end
240
+
241
+ # Takes one settled screenshot of the viewport or an element. Internal.
242
+ class Capture
243
+ STYLE_ID = 'raider-screenshot-settle'
244
+ FONTS_SCRIPT = <<~JS
245
+ var done = arguments[arguments.length - 1];
246
+ var timer = setTimeout(done, 2000);
247
+ (document.fonts ? document.fonts.ready : Promise.resolve()).then(function () { clearTimeout(timer); done(); });
248
+ JS
249
+ ADD_STYLE = <<~JS.tr("\n", ' ')
250
+ var style = document.createElement('style'); style.id = '#{STYLE_ID}';
251
+ style.textContent = '*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}';
252
+ document.head.appendChild(style);
253
+ JS
254
+ REMOVE_STYLE = "var style = document.getElementById('#{STYLE_ID}'); if (style) { style.remove(); }"
255
+ SCROLL = "arguments[0].scrollIntoView({ block: 'center', inline: 'center' });"
256
+
257
+ def initialize(driver, element)
258
+ @driver = driver
259
+ @element = element
260
+ end
261
+
262
+ # Fonts loaded, animations and the caret stopped; the stylesheet is gone again afterwards.
263
+ def png
264
+ @driver.execute_async_script(FONTS_SCRIPT)
265
+ @driver.execute_script(ADD_STYLE)
266
+ @element ? element_png : @driver.screenshot_as(:png)
267
+ ensure
268
+ remove_style
269
+ end
270
+
271
+ private
272
+
273
+ def element_png
274
+ @driver.execute_script(SCROLL, @element)
275
+ @element.screenshot_as(:png)
276
+ end
277
+
278
+ def remove_style
279
+ @driver.execute_script(REMOVE_STYLE)
280
+ rescue StandardError
281
+ nil
282
+ end
283
+ end
284
+
285
+ # Hands a comparison to raider_reporter while the test runs: its images, then the record. Internal.
286
+ module Report
287
+ IMAGES = %i[expected actual diff].freeze
288
+
289
+ # rubocop:disable Style/StderrPuts
290
+ def self.call(name:, status:, baseline:, result:, options:, images:)
291
+ return unless defined?(RaiderReporter)
292
+
293
+ attach_images(name, images)
294
+ record(name, status, baseline, result || sizes_of(images[:actual]), options)
295
+ rescue StandardError => e
296
+ $stderr.puts("[screenshots] could not report '#{name}': #{e.class}: #{e.message}")
297
+ end
298
+
299
+ # One trace step per comparison, when the trace helper is watching. Never raises into the test.
300
+ def self.traced(name:, status:, png:, error: nil)
301
+ return unless png
302
+
303
+ Raider.report(:screenshot_compared, name: name, status: status == :failed ? :failed : :passed, png: png,
304
+ error: error)
305
+ end
306
+
307
+ def self.attach_images(name, images)
308
+ Dir.mktmpdir('raider-screenshot') do |folder|
309
+ IMAGES.each do |kind|
310
+ next unless images[kind]
311
+
312
+ path = File.join(folder, "#{kind}.png")
313
+ File.binwrite(path, images[kind])
314
+ RaiderReporter.attach("comparison:#{name}:#{kind}", path, type: :png)
315
+ end
316
+ end
317
+ end
318
+
319
+ def self.record(name, status, baseline, result, options)
320
+ unless RaiderReporter.respond_to?(:comparison)
321
+ return if @warned
322
+
323
+ @warned = true
324
+ return $stderr.puts('[screenshots] this raider_reporter has no comparison method; update it to list comparisons in the report')
325
+ end
326
+
327
+ RaiderReporter.comparison(**fields(name, status, baseline, result), **options)
328
+ end
329
+ # rubocop:enable Style/StderrPuts
330
+
331
+ # With no diff result (created, or updated over an unreadable baseline) only the actual image has a size.
332
+ def self.sizes_of(png)
333
+ return unless png
334
+
335
+ Diff::Result.new(0, 0.0, nil, Diff.size_of(Diff.decode(png)))
336
+ end
337
+
338
+ def self.fields(name, status, baseline, result)
339
+ { name: name, status: status.to_s, baseline: baseline,
340
+ diff_pixels: result ? result.diff_pixels : 0, diff_ratio: result ? result.diff_ratio : 0.0,
341
+ expected_size: result&.expected_size, actual_size: result&.actual_size }
342
+ end
343
+ end
344
+
345
+ # One match_screenshot call: capture until the page matches the baseline or time runs out. Internal.
346
+ class Comparison
347
+ PAUSE = 0.25
348
+
349
+ def initialize(driver, name, element:, options:, timeout:)
350
+ @driver = driver
351
+ @name = name
352
+ @element = element
353
+ @options = options
354
+ @timeout = timeout
355
+ end
356
+
357
+ def call
358
+ Baseline.validate_name!(@name)
359
+ ScreenshotHelper.claim(@name)
360
+ @baseline = Baseline.new(@name, browser: ScreenshotHelper.browser)
361
+ @baseline.exist? ? compare : create
362
+ end
363
+
364
+ private
365
+
366
+ def create
367
+ status = ScreenshotHelper.decide(baseline_exists: false, matched: false)
368
+ raise Mismatch, missing_message if status == :missing_on_ci
369
+
370
+ actual = capture
371
+ @baseline.write(actual)
372
+ finish(status, nil, actual: actual)
373
+ end
374
+
375
+ def compare
376
+ expected = Diff.decode(@baseline.read)
377
+ actual_png, actual, result = capture_until_match(expected)
378
+ status = ScreenshotHelper.decide(baseline_exists: true, matched: within_limits?(result))
379
+ @baseline.write(actual_png) if status == :updated
380
+ finish(status, result, images(status, expected, actual, actual_png))
381
+ rescue Diff::Unreadable
382
+ unreadable
383
+ end
384
+
385
+ def unreadable
386
+ raise Mismatch, "baseline #{@baseline.path} is not a readable PNG" unless ScreenshotHelper.update?
387
+
388
+ actual = capture
389
+ @baseline.write(actual)
390
+ finish(:updated, nil, actual: actual)
391
+ end
392
+
393
+ # The expected, actual and diff pictures exist only for a failure; the diff is drawn once, here.
394
+ def images(status, expected, actual, actual_png)
395
+ return { actual: actual_png } if status == :updated
396
+ return {} unless status == :failed
397
+
398
+ { expected: @baseline.read, actual: actual_png,
399
+ diff: Diff.image(expected, actual, threshold: @options[:threshold]) }
400
+ end
401
+
402
+ def finish(status, result, images)
403
+ error = Mismatch.new(failure_message(result)) if status == :failed
404
+ Report.call(name: @name, status: status, baseline: @baseline.path, result: result,
405
+ options: @options, images: images)
406
+ Report.traced(name: @name, status: status, png: @actual, error: error)
407
+ raise error if error
408
+
409
+ true
410
+ end
411
+
412
+ def capture
413
+ @actual = Capture.new(@driver, @element).png
414
+ end
415
+
416
+ # Capture and count until the page matches or the deadline passes; the deadline is checked after every
417
+ # attempt, before the next capture. Cheap on every attempt: no diff picture.
418
+ def capture_until_match(expected)
419
+ deadline = now + (@timeout || Raider.timeout)
420
+ attempt = attempt_once(expected)
421
+ until within_limits?(attempt.last) || now >= deadline
422
+ sleep([[PAUSE, deadline - now].min, 0].max)
423
+ attempt = attempt_once(expected)
424
+ end
425
+ attempt
426
+ end
427
+
428
+ def attempt_once(expected)
429
+ png = capture
430
+ actual = Diff.decode(png)
431
+ [png, actual, Diff.count(expected, actual, threshold: @options[:threshold])]
432
+ end
433
+
434
+ def now
435
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
436
+ end
437
+
438
+ # Within the allowed pixel count, or within the allowed share when one is set.
439
+ def within_limits?(result)
440
+ return false if result.size_changed?
441
+
442
+ ratio = @options[:max_diff_ratio]
443
+ result.diff_pixels <= @options[:max_diff_pixels] || (!ratio.nil? && result.diff_ratio <= ratio)
444
+ end
445
+
446
+ def missing_message
447
+ "no baseline for '#{@name}' on #{ScreenshotHelper.browser}; " \
448
+ 'run it locally or with SCREENSHOTS=update and commit the file'
449
+ end
450
+
451
+ def failure_message(result)
452
+ "#{@baseline.path}: #{result.size_changed? ? size_detail(result) : pixel_detail(result)}"
453
+ end
454
+
455
+ def size_detail(result)
456
+ ratio = device_pixel_ratio
457
+ "size changed: baseline #{result.expected_size.join('x')}, now #{result.actual_size.join('x')}" \
458
+ "#{ratio ? " (device pixel ratio #{ratio})" : ''}"
459
+ end
460
+
461
+ def device_pixel_ratio
462
+ ratio = @driver.execute_script('return window.devicePixelRatio')
463
+ return unless ratio.is_a?(Numeric)
464
+
465
+ ratio == ratio.to_i ? ratio.to_i : ratio
466
+ rescue StandardError
467
+ nil
468
+ end
469
+
470
+ def pixel_detail(result)
471
+ count = result.diff_pixels
472
+ "#{count} #{count == 1 ? 'pixel' : 'pixels'} (#{format('%.2f', result.diff_ratio * 100)}%) differ " \
473
+ "(allowed: #{allowed}, threshold #{@options[:threshold]})"
474
+ end
475
+
476
+ def allowed
477
+ ratio = @options[:max_diff_ratio]
478
+ allowed = "#{@options[:max_diff_pixels]} pixels"
479
+ ratio ? "#{allowed} or #{format('%g', ratio * 100)}%" : allowed
480
+ end
481
+ end
482
+
483
+ def match_screenshot(name, element: nil, threshold: nil, max_diff_pixels: nil, max_diff_ratio: ScreenshotHelper::UNSET,
484
+ timeout: nil)
485
+ given = { threshold: threshold, max_diff_pixels: max_diff_pixels }.compact
486
+ given[:max_diff_ratio] = max_diff_ratio unless max_diff_ratio.equal?(ScreenshotHelper::UNSET)
487
+ options = ScreenshotHelper.config.merge(given)
488
+ Comparison.new(driver, name, element: element, options: options, timeout: timeout).call
489
+ end
490
+ end
@@ -6,15 +6,18 @@ require 'axe-rspec'
6
6
  require 'rspec'
7
7
  require 'rspec/retry'
8
8
  require 'raider_reporter/rspec'
9
+ require 'ruby_raider/runtime/rspec'
9
10
  require 'tmpdir'
10
- <%- if watir? -%>
11
- require_relative 'browser_helper'
12
- <%- else -%>
13
11
  require_relative 'driver_helper'
14
- <%- end -%>
15
12
  require_relative 'video_helper'
16
- <%- if wait_helper? -%>
17
- require_relative 'wait_helper'
13
+ <%- if mobile? -%>
14
+ require_relative 'gesture_helper'
15
+ <%- end -%>
16
+ <%- if network_helper? -%>
17
+ require_relative 'network_helper'
18
+ <%- end -%>
19
+ <%- if screenshot_helper? -%>
20
+ require_relative 'screenshot_helper'
18
21
  <%- end -%>
19
22
  <%- if web? -%>
20
23
  require_relative 'session_state'
@@ -32,13 +35,17 @@ module SpecHelper
32
35
  config.display_try_failure_messages = true
33
36
  config.default_retry_count = ENV.fetch('RETRY_COUNT', 0).to_i
34
37
  config.add_formatter(RaiderReporter::RSpec::Formatter)
35
- <%- if watir? -%>
36
- config.include(BrowserHelper)
37
- <%- else -%>
38
38
  config.include(DriverHelper)
39
+ config.include(Raider::Matchers)
40
+ config.include(Raider::Helpers)
41
+ <%- if mobile? -%>
42
+ config.include(GestureHelper)
39
43
  <%- end -%>
40
- <%- if wait_helper? -%>
41
- config.include(WaitHelper)
44
+ <%- if network_helper? -%>
45
+ config.include(NetworkHelper)
46
+ <%- end -%>
47
+ <%- if screenshot_helper? -%>
48
+ config.include(ScreenshotHelper)
42
49
  <%- end -%>
43
50
  <%- if mobile? -%>
44
51
  config.before(:each) do
@@ -51,17 +58,12 @@ module SpecHelper
51
58
  else
52
59
  driver.manage.window.maximize
53
60
  end
54
- <%- elsif watir? -%>
55
- config.before(:each) do
56
- viewport = YAML.load_file('config/config.yml')['viewport']
57
- if viewport
58
- browser.window.resize_to(viewport['width'], viewport['height'])
59
- else
60
- browser.window.maximize
61
- end
62
61
  <%- end -%>
63
- @video_recorder = VideoHelper.recorder_for(<%= watir? ? 'browser' : 'driver' %>)
62
+ @video_recorder = VideoHelper.recorder_for(driver)
64
63
  @video_recorder.start
64
+ <%- if screenshot_helper? -%>
65
+ ScreenshotHelper.reset
66
+ <%- end -%>
65
67
  <%- if trace_helper? -%>
66
68
  @trace_recorder = TraceHelper.recorder_for(driver) if defined?(TraceHelper)
67
69
  @trace_recorder&.start
@@ -71,32 +73,37 @@ module SpecHelper
71
73
 
72
74
  config.before(:each, :logged_in) do
73
75
  user = ModelFactory.for('users')['registered user']
74
- account = Account.new(<%= watir? ? 'browser' : 'driver' %>)
75
- SessionState.new(<%= watir? ? 'browser.driver' : 'driver' %>).reuse(
76
- valid: -> { account.tap(&:visit).header.customer_name == "Welcome back #{user['name']}" }
76
+ account = Account.new(driver)
77
+ SessionState.new(driver).reuse(
78
+ valid: -> { account.tap(&:visit).welcome_message.text == "Welcome back #{user['name']}" }
77
79
  ) do
78
- login = Login.new(<%= watir? ? 'browser' : 'driver' %>)
80
+ login = Login.new(driver)
79
81
  login.visit
80
82
  login.login(user['username'], user['password'])
81
83
  end
82
84
  end
83
85
  <%- end -%>
84
86
 
87
+ <%- failure_ref = network_helper? ? 'failure' : 'example.exception' -%>
85
88
  config.after(:each) do |example|
86
89
  example_name = example.description
90
+ <%- if network_helper? -%>
91
+ mock_error = NetworkHelper.finish(driver) if defined?(NetworkHelper)
92
+ failure = example.exception || mock_error
93
+ <%- end -%>
87
94
  <%- if trace_helper? -%>
88
95
  if defined?(TraceHelper)
89
96
  attempt = example.metadata[:retry_attempts].to_i + 1
90
- keep_trace = TraceHelper.keep_all? || (example.exception && (defined?(VideoHelper) ? VideoHelper.final_attempt?(example) : true))
97
+ keep_trace = TraceHelper.keep_all? || (<%= failure_ref %> && (defined?(VideoHelper) ? VideoHelper.final_attempt?(example) : true))
91
98
  trace_test = { identifier: example.id.delete_prefix('./'), full_name: example.full_description,
92
- status: example.exception ? 'failed' : 'passed', attempt: attempt, error: example.exception }
99
+ status: <%= failure_ref %> ? 'failed' : 'passed', attempt: attempt, error: <%= failure_ref %> }
93
100
  trace_path = @trace_recorder&.stop(keep: keep_trace, test: trace_test)
94
101
  end
95
102
  <%- end -%>
96
- keep_video = example.exception ? VideoHelper.final_attempt?(example) : false
103
+ keep_video = <%= failure_ref %> ? VideoHelper.final_attempt?(example) : false
97
104
  video_path = @video_recorder&.stop(keep: keep_video)
98
- if example.exception
99
- @_exception = example.exception
105
+ if <%= failure_ref %>
106
+ @_exception = <%= failure_ref %>
100
107
  Dir.mktmpdir do |temp_folder|
101
108
  screenshot_path = "#{temp_folder}/#{example_name}.png"
102
109
  <%= partial('screenshot', strip: true) %>
@@ -113,6 +120,9 @@ module SpecHelper
113
120
  <%- end -%>
114
121
  @video_recorder&.cleanup
115
122
  <%= partial('quit_driver', strip: true) %>
123
+ <%- if network_helper? -%>
124
+ raise mock_error if mock_error
125
+ <%- end -%>
116
126
  end
117
127
  end
118
128
  end