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,776 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chunky_png'
4
+ require 'erb'
5
+ require 'fileutils'
6
+ require 'tmpdir'
7
+ require_relative '../../lib/generators/helper_generator'
8
+ require_relative '../../lib/ruby_raider/runtime'
9
+
10
+ # Renders screenshot_helper.tt for a selenium/rspec project and loads it.
11
+ module ScreenshotHelperRenderer
12
+ TEMPLATE = File.expand_path('../../lib/generators/templates/helpers/screenshot_helper.tt', __dir__)
13
+
14
+ def render_and_load_screenshot_helper
15
+ generator = HelpersGenerator.new(%w[selenium rspec proj])
16
+ rendered = ERB.new(File.read(TEMPLATE), trim_mode: '-').result(generator.instance_eval { binding })
17
+ Dir.mktmpdir('screenshot-helper') do |dir|
18
+ path = File.join(dir, 'screenshot_helper.rb')
19
+ File.write(path, rendered)
20
+ Object.send(:remove_const, :ScreenshotHelper) if Object.const_defined?(:ScreenshotHelper)
21
+ load path
22
+ end
23
+ end
24
+ end
25
+
26
+ RSpec.describe 'ScreenshotHelper' do
27
+ include ScreenshotHelperRenderer
28
+
29
+ before { render_and_load_screenshot_helper }
30
+
31
+ it 'raises Mismatch as a failed expectation' do
32
+ expect(ScreenshotHelper::Mismatch.ancestors).to include(RSpec::Expectations::ExpectationNotMetError)
33
+ end
34
+
35
+ describe '.config' do
36
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
37
+
38
+ it 'uses the defaults without a config file' do
39
+ expect(ScreenshotHelper.config).to eq(threshold: 0.2, max_diff_pixels: 0, max_diff_ratio: nil)
40
+ end
41
+
42
+ it 'uses the defaults when config.yml has no screenshots block' do
43
+ FileUtils.mkdir_p('config')
44
+ File.write('config/config.yml', "browser: chrome\n")
45
+ expect(ScreenshotHelper.config).to eq(threshold: 0.2, max_diff_pixels: 0, max_diff_ratio: nil)
46
+ end
47
+
48
+ def config_with(block)
49
+ FileUtils.mkdir_p('config')
50
+ File.write('config/config.yml', "screenshots:\n#{block}")
51
+ ScreenshotHelper.config
52
+ end
53
+
54
+ it 'falls back to the default with one warning for an invalid value' do
55
+ ScreenshotHelper.instance_variable_get(:@warned).clear
56
+ bad = " threshold: 2\n max_diff_pixels: -1\n max_diff_ratio: lots\n"
57
+ expect { 2.times { expect(config_with(bad)).to eq(ScreenshotHelper::DEFAULTS) } }
58
+ .to output(/threshold: 2.*\n.*max_diff_pixels: -1.*\n.*max_diff_ratio: "lots"/).to_stderr
59
+ end
60
+
61
+ it 'warns once per bad value, not on every call' do
62
+ ScreenshotHelper.instance_variable_get(:@warned).clear
63
+ expect { 3.times { config_with(" threshold: high\n") } }.to output(/\A[^\n]*threshold[^\n]*\n\z/).to_stderr
64
+ end
65
+
66
+ it 'treats a blank threshold as invalid and a blank ratio as none' do
67
+ ScreenshotHelper.instance_variable_get(:@warned).clear
68
+ expect { expect(config_with(" threshold:\n max_diff_ratio:\n")).to eq(ScreenshotHelper::DEFAULTS) }
69
+ .to output(/threshold/).to_stderr
70
+ end
71
+
72
+ it 'accepts a whole number threshold and a float max_diff_pixels is invalid' do
73
+ ScreenshotHelper.instance_variable_get(:@warned).clear
74
+ expect { expect(config_with(" threshold: 1\n max_diff_pixels: 2.5\n")).to include(threshold: 1, max_diff_pixels: 0) }
75
+ .to output(/max_diff_pixels/).to_stderr
76
+ end
77
+
78
+ it 'reads the screenshots block over the defaults' do
79
+ FileUtils.mkdir_p('config')
80
+ File.write('config/config.yml', "screenshots:\n threshold: 0.1\n max_diff_ratio: 0.001\n")
81
+ expect(ScreenshotHelper.config).to eq(threshold: 0.1, max_diff_pixels: 0, max_diff_ratio: 0.001)
82
+ end
83
+ end
84
+
85
+ describe 'ScreenshotHelper::Diff' do
86
+ def png(width, height, color = ChunkyPNG::Color::WHITE)
87
+ image = ChunkyPNG::Image.new(width, height, color)
88
+ yield image if block_given?
89
+ image.to_blob
90
+ end
91
+
92
+ def decode(bytes)
93
+ ScreenshotHelper::Diff.decode(bytes)
94
+ end
95
+
96
+ def compare(expected, actual, threshold: 0.2)
97
+ ScreenshotHelper::Diff.count(decode(expected), decode(actual), threshold: threshold)
98
+ end
99
+
100
+ def diff_image(expected, actual, threshold: 0.2)
101
+ ChunkyPNG::Image.from_blob(
102
+ ScreenshotHelper::Diff.image(decode(expected), decode(actual), threshold: threshold)
103
+ )
104
+ end
105
+
106
+ it 'finds no difference between identical images' do
107
+ result = compare(png(10, 10), png(10, 10))
108
+ expect([result.diff_pixels, result.diff_ratio, result.size_changed?]).to eq([0, 0.0, false])
109
+ end
110
+
111
+ it 'counts one changed pixel and its share of the image' do
112
+ changed = png(10, 10) { |image| image[3, 4] = ChunkyPNG::Color::BLACK }
113
+ result = compare(png(10, 10), changed)
114
+ expect([result.diff_pixels, result.diff_ratio]).to eq([1, 0.01])
115
+ end
116
+
117
+ it 'lets the threshold decide whether a small shade counts' do
118
+ grey = ChunkyPNG::Color.rgb(0x80, 0x80, 0x80)
119
+ slightly = ChunkyPNG::Color.rgb(0x82, 0x82, 0x82)
120
+ expected = png(4, 4, grey)
121
+ actual = png(4, 4, grey) { |image| image[0, 0] = slightly }
122
+ expect(compare(expected, actual, threshold: 0.2).diff_pixels).to eq(0)
123
+ expect(compare(expected, actual, threshold: 0).diff_pixels).to eq(1)
124
+ end
125
+
126
+ it 'reports a size change without counting pixels' do
127
+ result = compare(png(10, 10), png(10, 12))
128
+ expect([result.size_changed?, result.expected_size, result.actual_size, result.diff_pixels, result.diff_ratio])
129
+ .to eq([true, [10, 10], [10, 12], 0, 0.0])
130
+ end
131
+
132
+ it 'draws both images side by side after a size change' do
133
+ diff = diff_image(png(10, 10), png(10, 12))
134
+ expect([diff.width, diff.height]).to eq([20, 12])
135
+ end
136
+
137
+ it 'does not compare colours of equal pixels' do
138
+ allow(ScreenshotHelper::Diff).to receive(:delta).and_call_original
139
+ compare(png(10, 10), png(10, 10, ChunkyPNG::Color::WHITE))
140
+ expect(ScreenshotHelper::Diff).not_to have_received(:delta)
141
+ end
142
+
143
+ it 'blends transparent pixels over white before comparing' do
144
+ transparent = png(5, 5, ChunkyPNG::Color::TRANSPARENT)
145
+ expect(compare(transparent, png(5, 5), threshold: 0).diff_pixels).to eq(0)
146
+ end
147
+
148
+ it 'paints a changed pixel red and the rest grey in the diff image' do
149
+ changed = png(10, 10) { |image| image[3, 4] = ChunkyPNG::Color.rgb(0, 0, 0) }
150
+ diff = diff_image(png(10, 10), changed)
151
+ expect(diff[3, 4]).to eq(ChunkyPNG::Color.rgb(255, 0, 0))
152
+ expect(ChunkyPNG::Color.r(diff[0, 0])).to eq(ChunkyPNG::Color.g(diff[0, 0]))
153
+ expect(diff[0, 0]).not_to eq(diff[3, 4])
154
+ end
155
+
156
+ it 'raises Unreadable for bytes that are not a PNG' do
157
+ expect { decode('garbage') }.to raise_error(ScreenshotHelper::Diff::Unreadable)
158
+ end
159
+ end
160
+
161
+ describe 'ScreenshotHelper::Baseline' do
162
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
163
+
164
+ it 'accepts plain and nested names' do
165
+ %w[home checkout/summary a-b_c].each do |name|
166
+ expect { ScreenshotHelper::Baseline.validate_name!(name) }.not_to raise_error
167
+ end
168
+ end
169
+
170
+ it 'rejects names that could leave the baseline folder or are malformed' do
171
+ ['../x', '/x', 'a//b', 'a.png', '', 'a/', 'a' * 101, nil].each do |name|
172
+ expect { ScreenshotHelper::Baseline.validate_name!(name) }.to raise_error(ArgumentError)
173
+ end
174
+ end
175
+
176
+ it 'accepts a name of exactly 100 characters' do
177
+ expect { ScreenshotHelper::Baseline.validate_name!('a' * 100) }.not_to raise_error
178
+ end
179
+
180
+ it 'refuses a bad name before anything is written' do
181
+ expect { ScreenshotHelper::Baseline.new('../x', browser: 'chrome') }.to raise_error(ArgumentError)
182
+ expect(Dir.children('.')).to be_empty
183
+ end
184
+
185
+ it 'builds a relative path under the browser folder' do
186
+ baseline = ScreenshotHelper::Baseline.new('checkout/summary', browser: 'edge')
187
+ expect(baseline.path).to eq('screenshots/baselines/edge/checkout/summary.png')
188
+ end
189
+
190
+ it 'writes atomically, creating folders and leaving no temp file' do
191
+ baseline = ScreenshotHelper::Baseline.new('a/b', browser: 'chrome')
192
+ expect(baseline.exist?).to be(false)
193
+ baseline.write('png bytes')
194
+ expect(baseline.exist?).to be(true)
195
+ expect(baseline.read).to eq('png bytes')
196
+ expect(Dir.children('screenshots/baselines/chrome/a')).to eq(['b.png'])
197
+ end
198
+
199
+ it 'resolves the path against root' do
200
+ Dir.mktmpdir do |root|
201
+ ScreenshotHelper::Baseline.new('x', browser: 'chrome', root: root).write('1')
202
+ expect(File.read(File.join(root, 'screenshots/baselines/chrome/x.png'))).to eq('1')
203
+ end
204
+ end
205
+ end
206
+
207
+ describe '.browser' do
208
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
209
+
210
+ def browser_from(value)
211
+ FileUtils.mkdir_p('config')
212
+ File.write('config/config.yml', "browser: #{value}\n")
213
+ ScreenshotHelper.browser
214
+ end
215
+
216
+ it 'names the folder after the configured browser, without the microsoft prefix' do
217
+ expect(%w[chrome MicrosoftEdge edge Firefox].map { |value| browser_from(value) })
218
+ .to eq(%w[chrome edge edge firefox])
219
+ end
220
+
221
+ it 'falls back to chrome without a config' do
222
+ expect(ScreenshotHelper.browser).to eq('chrome')
223
+ end
224
+
225
+ it 'falls back to chrome for a value that could leave the baselines folder' do
226
+ ['../../x', 'a/b', '"my browser"', '.'].each { |value| expect(browser_from(value)).to eq('chrome') }
227
+ end
228
+ end
229
+
230
+ describe '.update? and .ci?' do
231
+ around do |example|
232
+ saved = ENV.to_h.slice('SCREENSHOTS', 'CI')
233
+ example.run
234
+ ensure
235
+ %w[SCREENSHOTS CI].each { |key| ENV.delete(key) }
236
+ ENV.update(saved)
237
+ end
238
+
239
+ it 'updates only when SCREENSHOTS=update, in any case' do
240
+ ENV.delete('SCREENSHOTS')
241
+ expect(ScreenshotHelper.update?).to be(false)
242
+ ENV['SCREENSHOTS'] = 'Update'
243
+ expect(ScreenshotHelper.update?).to be(true)
244
+ ENV['SCREENSHOTS'] = 'no'
245
+ expect(ScreenshotHelper.update?).to be(false)
246
+ end
247
+
248
+ it 'detects CI through the reporter, or CI in the environment' do
249
+ hide_const('RaiderReporter')
250
+ ENV.delete('CI')
251
+ expect(ScreenshotHelper.ci?).to be(false)
252
+ ENV['CI'] = 'true'
253
+ expect(ScreenshotHelper.ci?).to be(true)
254
+ end
255
+
256
+ it 'uses the reporter detector when it is there' do
257
+ ENV.delete('CI')
258
+ detector = instance_double('detector', ci: { provider: 'github' })
259
+ stub_const('RaiderReporter::CiDetector', class_double('RaiderReporter::CiDetector', new: detector))
260
+ expect(ScreenshotHelper.ci?).to be(true)
261
+ end
262
+
263
+ it 'falls back to the environment when the detector fails' do
264
+ ENV['CI'] = '1'
265
+ stub_const('RaiderReporter::CiDetector', Class.new { define_method(:initialize) { raise 'boom' } })
266
+ expect(ScreenshotHelper.ci?).to be(true)
267
+ end
268
+ end
269
+
270
+ describe '.decide' do
271
+ def decide(exists:, matched:, on_ci: false, update: false)
272
+ allow(ScreenshotHelper).to receive_messages(ci?: on_ci, update?: update)
273
+ ScreenshotHelper.decide(baseline_exists: exists, matched: matched)
274
+ end
275
+
276
+ it 'creates a missing baseline locally' do
277
+ expect(decide(exists: false, matched: false)).to eq(:created)
278
+ end
279
+
280
+ it 'fails a missing baseline on CI unless asked to update' do
281
+ expect(decide(exists: false, matched: false, on_ci: true)).to eq(:missing_on_ci)
282
+ expect(decide(exists: false, matched: false, on_ci: true, update: true)).to eq(:created)
283
+ end
284
+
285
+ it 'updates a mismatch only when asked to' do
286
+ expect(decide(exists: true, matched: false, update: true)).to eq(:updated)
287
+ expect(decide(exists: true, matched: false)).to eq(:failed)
288
+ end
289
+
290
+ it 'passes a match, update or not' do
291
+ expect(decide(exists: true, matched: true)).to eq(:passed)
292
+ expect(decide(exists: true, matched: true, update: true)).to eq(:passed)
293
+ end
294
+ end
295
+
296
+ describe '#match_screenshot' do
297
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
298
+
299
+ before do
300
+ ScreenshotHelper.reset
301
+ allow(ScreenshotHelper).to receive_messages(ci?: false, update?: false)
302
+ end
303
+
304
+ # Answers like a Selenium driver: queued PNGs (the last one repeats), scripts logged as events.
305
+ let(:driver_class) do
306
+ Class.new do
307
+ attr_reader :events, :captures
308
+
309
+ def initialize(*pngs, ratio: 2, failing: false)
310
+ @pngs = pngs
311
+ @ratio = ratio
312
+ @failing = failing
313
+ @events = []
314
+ @captures = 0
315
+ end
316
+
317
+ def screenshot_as(_format)
318
+ @events << :capture
319
+ raise 'capture failed' if @failing
320
+
321
+ @captures += 1
322
+ @pngs[[@captures - 1, @pngs.length - 1].min]
323
+ end
324
+
325
+ def execute_async_script(_script)
326
+ @events << :fonts
327
+ end
328
+
329
+ def execute_script(script, *_args)
330
+ @events << :add_style if script.include?('appendChild')
331
+ @events << :remove_style if script.include?('.remove()')
332
+ @events << :scroll if script.include?('scrollIntoView')
333
+ raise 'no ratio' if script.include?('devicePixelRatio') && @ratio.nil?
334
+
335
+ @ratio if script.include?('devicePixelRatio')
336
+ end
337
+ end
338
+ end
339
+
340
+ def png(width = 10, height = 10, color = ChunkyPNG::Color::WHITE)
341
+ image = ChunkyPNG::Image.new(width, height, color)
342
+ yield image if block_given?
343
+ image.to_blob
344
+ end
345
+
346
+ def different
347
+ png { |image| image[1, 1] = ChunkyPNG::Color::BLACK }
348
+ end
349
+
350
+ def host_for(driver)
351
+ Class.new do
352
+ include ScreenshotHelper
353
+
354
+ attr_reader :driver
355
+
356
+ def initialize(driver)
357
+ @driver = driver
358
+ end
359
+ end.new(driver)
360
+ end
361
+
362
+ def baseline_path(name = 'home', browser = 'chrome')
363
+ "screenshots/baselines/#{browser}/#{name}.png"
364
+ end
365
+
366
+ def store_baseline(bytes, name = 'home')
367
+ FileUtils.mkdir_p(File.dirname(baseline_path(name)))
368
+ File.binwrite(baseline_path(name), bytes)
369
+ end
370
+
371
+ it 'passes when the page matches the baseline' do
372
+ store_baseline(png)
373
+ driver = driver_class.new(png)
374
+ expect(host_for(driver).match_screenshot('home')).to be(true)
375
+ expect(driver.captures).to eq(1)
376
+ end
377
+
378
+ it 'treats names that differ only in case as the same screenshot' do
379
+ host = host_for(driver_class.new(png))
380
+ host.match_screenshot('Home')
381
+ expect { host.match_screenshot('home') }.to raise_error(ArgumentError, /already used/)
382
+ end
383
+
384
+ it 'lets an explicit max_diff_ratio: nil override a configured ratio' do
385
+ FileUtils.mkdir_p('config')
386
+ File.write('config/config.yml', "screenshots:\n max_diff_ratio: 0.05\n")
387
+ store_baseline(png)
388
+ expect(host_for(driver_class.new(different)).match_screenshot('home', timeout: 0)).to be(true)
389
+ ScreenshotHelper.reset
390
+ expect { host_for(driver_class.new(different)).match_screenshot('home', max_diff_ratio: nil, timeout: 0) }
391
+ .to raise_error(ScreenshotHelper::Mismatch)
392
+ end
393
+
394
+ it 'draws the diff image once, for a final failure only' do
395
+ store_baseline(png)
396
+ allow(ScreenshotHelper::Diff).to receive(:image).and_call_original
397
+ host_for(driver_class.new(different, different, png)).match_screenshot('home', timeout: 5)
398
+ expect(ScreenshotHelper::Diff).not_to have_received(:image)
399
+ ScreenshotHelper.reset
400
+ driver = driver_class.new(different)
401
+ expect { host_for(driver).match_screenshot('home', timeout: 0.6) }.to raise_error(ScreenshotHelper::Mismatch)
402
+ expect(driver.captures).to be >= 2
403
+ expect(ScreenshotHelper::Diff).to have_received(:image).once
404
+ end
405
+
406
+ it 'decodes the baseline once however many attempts it takes' do
407
+ store_baseline(png)
408
+ allow(ScreenshotHelper::Diff).to receive(:decode).and_call_original
409
+ host_for(driver_class.new(different, different, png)).match_screenshot('home', timeout: 5)
410
+ expect(ScreenshotHelper::Diff).to have_received(:decode).exactly(4).times
411
+ end
412
+
413
+ it 'retries a size change and passes when the element settles' do
414
+ store_baseline(png)
415
+ expect(host_for(driver_class.new(png(5, 5), png)).match_screenshot('home', timeout: 5)).to be(true)
416
+ end
417
+
418
+ it 'does not start another capture after the deadline' do
419
+ store_baseline(png)
420
+ driver = driver_class.new(different)
421
+ expect { host_for(driver).match_screenshot('home', timeout: 0) }.to raise_error(ScreenshotHelper::Mismatch)
422
+ expect(driver.captures).to eq(1)
423
+ end
424
+
425
+ it 'retries after a difference and passes once the page matches' do
426
+ store_baseline(png)
427
+ driver = driver_class.new(different, png)
428
+ expect(host_for(driver).match_screenshot('home', timeout: 5)).to be(true)
429
+ expect(driver.captures).to eq(2)
430
+ end
431
+
432
+ it 'raises Mismatch after the timeout, naming the baseline, the pixels and the limits' do
433
+ store_baseline(png)
434
+ driver = driver_class.new(different)
435
+ expect { host_for(driver).match_screenshot('home', timeout: 0.3) }
436
+ .to raise_error(ScreenshotHelper::Mismatch) { |error|
437
+ expect(error.message).to include(baseline_path, '1 pixel (1.00%) differ', 'threshold 0.2', '0 pixels')
438
+ }
439
+ expect(driver.captures).to be >= 2
440
+ end
441
+
442
+ it 'honours max_diff_pixels and max_diff_ratio' do
443
+ store_baseline(png)
444
+ expect(host_for(driver_class.new(different)).match_screenshot('home', max_diff_pixels: 1, timeout: 0)).to be(true)
445
+ ScreenshotHelper.reset
446
+ expect(host_for(driver_class.new(different)).match_screenshot('home', max_diff_ratio: 0.05, timeout: 0)).to be(true)
447
+ end
448
+
449
+ it 'reads the defaults from config.yml' do
450
+ FileUtils.mkdir_p('config')
451
+ File.write('config/config.yml', "screenshots:\n max_diff_pixels: 5\n")
452
+ store_baseline(png)
453
+ expect(host_for(driver_class.new(different)).match_screenshot('home', timeout: 0)).to be(true)
454
+ end
455
+
456
+ it 'fails a size change naming both sizes and the device pixel ratio, without comparing pixels' do
457
+ store_baseline(png(20, 10))
458
+ driver = driver_class.new(png(40, 20), ratio: 2)
459
+ expect { host_for(driver).match_screenshot('home', timeout: 0) }
460
+ .to raise_error(ScreenshotHelper::Mismatch, /size changed: baseline 20x10, now 40x20 \(device pixel ratio 2\)/)
461
+ end
462
+
463
+ it 'leaves the device pixel ratio out when it cannot be read' do
464
+ store_baseline(png(20, 10))
465
+ expect { host_for(driver_class.new(png(40, 20), ratio: nil)).match_screenshot('home', timeout: 0) }
466
+ .to raise_error(ScreenshotHelper::Mismatch) { |error|
467
+ expect(error.message).to include('size changed: baseline 20x10, now 40x20')
468
+ expect(error.message).not_to include('device pixel ratio')
469
+ }
470
+ end
471
+
472
+ it 'writes a missing baseline outside CI and passes' do
473
+ expect(host_for(driver_class.new(png)).match_screenshot('home')).to be(true)
474
+ expect(File.binread(baseline_path)).to eq(png)
475
+ end
476
+
477
+ it 'fails a missing baseline on CI and writes nothing' do
478
+ allow(ScreenshotHelper).to receive(:ci?).and_return(true)
479
+ driver = driver_class.new(png)
480
+ expect { host_for(driver).match_screenshot('home') }.to raise_error(
481
+ ScreenshotHelper::Mismatch,
482
+ "no baseline for 'home' on chrome; run it locally or with SCREENSHOTS=update and commit the file"
483
+ )
484
+ expect(File.exist?('screenshots')).to be(false)
485
+ end
486
+
487
+ it 'writes a missing baseline on CI when asked to update' do
488
+ allow(ScreenshotHelper).to receive_messages(ci?: true, update?: true)
489
+ expect(host_for(driver_class.new(png)).match_screenshot('home')).to be(true)
490
+ expect(File.exist?(baseline_path)).to be(true)
491
+ end
492
+
493
+ it 'rewrites a differing baseline when asked to update' do
494
+ allow(ScreenshotHelper).to receive(:update?).and_return(true)
495
+ store_baseline(png)
496
+ expect(host_for(driver_class.new(different)).match_screenshot('home', timeout: 0)).to be(true)
497
+ expect(File.binread(baseline_path)).to eq(different)
498
+ end
499
+
500
+ it 'fails an unreadable baseline and leaves the file alone' do
501
+ store_baseline('not a png')
502
+ expect { host_for(driver_class.new(png)).match_screenshot('home', timeout: 0) }
503
+ .to raise_error(ScreenshotHelper::Mismatch, /baseline .*home\.png is not a readable PNG/)
504
+ expect(File.binread(baseline_path)).to eq('not a png')
505
+ end
506
+
507
+ it 'replaces an unreadable baseline when asked to update' do
508
+ allow(ScreenshotHelper).to receive(:update?).and_return(true)
509
+ store_baseline('not a png')
510
+ expect(host_for(driver_class.new(png)).match_screenshot('home')).to be(true)
511
+ expect(File.binread(baseline_path)).to eq(png)
512
+ end
513
+
514
+ it 'refuses a bad name before any capture' do
515
+ driver = driver_class.new(png)
516
+ expect { host_for(driver).match_screenshot('../x') }.to raise_error(ArgumentError)
517
+ expect(driver.events).to be_empty
518
+ end
519
+
520
+ it 'refuses the same name twice in one test until reset' do
521
+ store_baseline(png)
522
+ host = host_for(driver_class.new(png))
523
+ host.match_screenshot('home')
524
+ expect { host.match_screenshot('home') }.to raise_error(ArgumentError, /already used/)
525
+ ScreenshotHelper.reset
526
+ expect(host.match_screenshot('home')).to be(true)
527
+ end
528
+
529
+ it 'compares an element, scrolled into view' do
530
+ store_baseline(png)
531
+ element = instance_double('element')
532
+ allow(element).to receive(:screenshot_as).with(:png).and_return(png)
533
+ driver = driver_class.new(different)
534
+ expect(host_for(driver).match_screenshot('home', element: element)).to be(true)
535
+ expect(driver.captures).to eq(0)
536
+ expect(driver.events).to include(:scroll)
537
+ end
538
+
539
+ it 'adds the settle stylesheet before the capture and removes it after' do
540
+ store_baseline(png)
541
+ driver = driver_class.new(png)
542
+ host_for(driver).match_screenshot('home')
543
+ expect(driver.events.reject { |event| event == :scroll })
544
+ .to eq(%i[fonts add_style capture remove_style])
545
+ end
546
+
547
+ it 'removes the settle stylesheet when the capture raises' do
548
+ store_baseline(png)
549
+ driver = driver_class.new(png, failing: true)
550
+ expect { host_for(driver).match_screenshot('home') }.to raise_error(RuntimeError, 'capture failed')
551
+ expect(driver.events.last).to eq(:remove_style)
552
+ end
553
+ end
554
+
555
+ describe 'trace step' do
556
+ around { |example| Dir.mktmpdir { |dir| Dir.chdir(dir) { example.run } } }
557
+
558
+ let(:seen) { [] }
559
+ let(:driver_class) do
560
+ Class.new do
561
+ def initialize(png)
562
+ @png = png
563
+ end
564
+
565
+ def screenshot_as(_format)
566
+ @png
567
+ end
568
+
569
+ def execute_async_script(_script); end
570
+
571
+ def execute_script(*); end
572
+ end
573
+ end
574
+ let(:host_class) do
575
+ Class.new do
576
+ include ScreenshotHelper
577
+ attr_reader :driver
578
+
579
+ def initialize(driver)
580
+ @driver = driver
581
+ end
582
+ end
583
+ end
584
+ let(:white) { ChunkyPNG::Image.new(10, 10, ChunkyPNG::Color::WHITE).to_blob }
585
+ let(:black) { ChunkyPNG::Image.new(10, 10, ChunkyPNG::Color::BLACK).to_blob }
586
+
587
+ before do
588
+ ScreenshotHelper.reset
589
+ allow(ScreenshotHelper).to receive_messages(ci?: false, update?: false)
590
+ seen = self.seen
591
+ observer = Object.new
592
+ observer.define_singleton_method(:screenshot_compared) { |**details| seen.replace([:screenshot_compared, details]) }
593
+ Raider.observer = observer
594
+ end
595
+
596
+ after do
597
+ Raider.observer = nil
598
+ end
599
+
600
+ def match(png, name: 'home', **options)
601
+ host_class.new(driver_class.new(png)).match_screenshot(name, timeout: 0, **options)
602
+ end
603
+
604
+ def store(bytes)
605
+ FileUtils.mkdir_p('screenshots/baselines/chrome')
606
+ File.binwrite('screenshots/baselines/chrome/home.png', bytes)
607
+ end
608
+
609
+ it 'reports a created comparison as passed with the actual PNG' do
610
+ match(white)
611
+ expect(seen).to eq([:screenshot_compared, { name: 'home', status: :passed, png: white, error: nil }])
612
+ end
613
+
614
+ it 'reports a passed comparison once' do
615
+ store(white)
616
+ match(white)
617
+ expect(seen).to eq([:screenshot_compared, { name: 'home', status: :passed, png: white, error: nil }])
618
+ end
619
+
620
+ it 'reports a failed comparison before the Mismatch is raised, with the last actual PNG' do
621
+ store(white)
622
+ expect { match(black) }.to raise_error(ScreenshotHelper::Mismatch) { |raised|
623
+ expect(seen.last).to include(name: 'home', status: :failed, png: black)
624
+ expect(seen.last[:error]).to be(raised)
625
+ }
626
+ end
627
+
628
+ it 'reports an updated comparison as passed' do
629
+ store(white)
630
+ allow(ScreenshotHelper).to receive(:update?).and_return(true)
631
+ match(black)
632
+ expect(seen).to eq([:screenshot_compared, { name: 'home', status: :passed, png: black, error: nil }])
633
+ end
634
+
635
+ it 'reports nothing when no screenshot was taken' do
636
+ allow(ScreenshotHelper).to receive(:ci?).and_return(true)
637
+ expect { match(white) }.to raise_error(ScreenshotHelper::Mismatch, /no baseline/)
638
+ expect(seen).to be_empty
639
+ end
640
+ end
641
+
642
+ describe 'Raider.report' do
643
+ after { Raider.observer = nil }
644
+
645
+ it 'passes the hook to an observer that has it and leaves one without it alone, silently' do
646
+ seen = []
647
+ Raider.observer = Class.new { define_method(:screenshot_compared) { |**details| seen << details } }.new
648
+ Raider.report(:screenshot_compared, name: 'a', status: :passed, png: 'x')
649
+ expect(seen).to eq([{ name: 'a', status: :passed, png: 'x' }])
650
+
651
+ other = Object.new
652
+ Raider.observer = other
653
+ expect { Raider.report(:screenshot_compared, name: 'a', status: :passed, png: 'x') }.not_to output.to_stderr
654
+ expect(Raider.observer).to be(other)
655
+ end
656
+ end
657
+
658
+ describe 'ScreenshotHelper::Report' do
659
+ let(:attached) { [] }
660
+ let(:recorded) { [] }
661
+ let(:reporter) do
662
+ files = attached
663
+ records = recorded
664
+ Module.new do
665
+ define_singleton_method(:attach) do |name, path, **options|
666
+ files << [name, path, options, File.exist?(path), File.binread(path)]
667
+ end
668
+ define_singleton_method(:comparison) { |**fields| records << fields }
669
+ end
670
+ end
671
+ let(:diff_result) do
672
+ ScreenshotHelper::Diff::Result.new(1, 0.01, [10, 10], [10, 10])
673
+ end
674
+ let(:options) { { threshold: 0.2, max_diff_pixels: 0, max_diff_ratio: nil } }
675
+
676
+ def report(status, result: diff_result, images: {})
677
+ ScreenshotHelper::Report.call(name: 'home', status: status, baseline: 'screenshots/baselines/chrome/home.png',
678
+ result: result, options: options, images: images)
679
+ end
680
+
681
+ it 'attaches the three images of a failed comparison, then records it' do
682
+ stub_const('RaiderReporter', reporter)
683
+ report(:failed, images: { expected: 'e', actual: 'a', diff: 'd' })
684
+ expect(attached.map { |file| file.first(3) }).to eq(
685
+ [['comparison:home:expected', attached[0][1], { type: :png }],
686
+ ['comparison:home:actual', attached[1][1], { type: :png }],
687
+ ['comparison:home:diff', attached[2][1], { type: :png }]]
688
+ )
689
+ expect(attached.map { |file| file[4] }).to eq(%w[e a d])
690
+ expect(recorded).to eq(
691
+ [{ name: 'home', status: 'failed', baseline: 'screenshots/baselines/chrome/home.png', diff_pixels: 1,
692
+ diff_ratio: 0.01, threshold: 0.2, max_diff_pixels: 0, max_diff_ratio: nil,
693
+ expected_size: [10, 10], actual_size: [10, 10] }]
694
+ )
695
+ end
696
+
697
+ it 'attaches only the actual image of a created comparison, with zero differences' do
698
+ stub_const('RaiderReporter', reporter)
699
+ report(:created, result: nil, images: { actual: ChunkyPNG::Image.new(7, 5).to_blob })
700
+ expect(attached.map(&:first)).to eq(['comparison:home:actual'])
701
+ expect(recorded.first).to include(status: 'created', diff_pixels: 0, diff_ratio: 0.0, expected_size: nil,
702
+ actual_size: [7, 5])
703
+ end
704
+
705
+ it 'gives an updated comparison without a diff result the size of the actual image' do
706
+ stub_const('RaiderReporter', reporter)
707
+ report(:updated, result: nil, images: { actual: ChunkyPNG::Image.new(3, 4).to_blob })
708
+ expect(recorded.first).to include(status: 'updated', expected_size: nil, actual_size: [3, 4])
709
+ end
710
+
711
+ it 'keeps the real sizes of an updated comparison that had a diff result' do
712
+ stub_const('RaiderReporter', reporter)
713
+ report(:updated, images: { actual: ChunkyPNG::Image.new(3, 4).to_blob })
714
+ expect(recorded.first).to include(expected_size: [10, 10], actual_size: [10, 10])
715
+ end
716
+
717
+ it 'attaches nothing for a passed comparison but still records it' do
718
+ stub_const('RaiderReporter', reporter)
719
+ report(:passed)
720
+ expect(attached).to be_empty
721
+ expect(recorded.map { |fields| fields[:status] }).to eq(['passed'])
722
+ end
723
+
724
+ it 'removes the temp files afterwards' do
725
+ stub_const('RaiderReporter', reporter)
726
+ report(:failed, images: { expected: 'e', actual: 'a', diff: 'd' })
727
+ expect(attached.map { |file| file[3] }).to all(be(true))
728
+ expect(attached.map { |file| File.exist?(file[1]) }).to all(be(false))
729
+ end
730
+
731
+ it 'attaches only and warns once when the reporter has no comparison method' do
732
+ old = Module.new
733
+ files = attached
734
+ old.define_singleton_method(:attach) { |name, path, **_| files << [name, path] }
735
+ stub_const('RaiderReporter', old)
736
+ expect { 2.times { report(:failed, images: { actual: 'a' }) } }
737
+ .to output(/raider_reporter.*comparison/m).to_stderr
738
+ expect(attached.length).to eq(2)
739
+ end
740
+
741
+ it 'does nothing without raider_reporter' do
742
+ hide_const('RaiderReporter')
743
+ expect { report(:failed, images: { actual: 'a' }) }.not_to raise_error
744
+ end
745
+
746
+ def host_with(png)
747
+ driver = Struct.new(:png) do
748
+ def screenshot_as(_format)
749
+ png
750
+ end
751
+
752
+ def execute_async_script(_script); end
753
+
754
+ def execute_script(*); end
755
+ end.new(png)
756
+ Class.new { include ScreenshotHelper }.new.tap { |host| host.define_singleton_method(:driver) { driver } }
757
+ end
758
+
759
+ it 'is called by match_screenshot before it raises' do
760
+ stub_const('RaiderReporter', reporter)
761
+ ScreenshotHelper.reset
762
+ allow(ScreenshotHelper).to receive_messages(ci?: false, update?: false)
763
+ Dir.mktmpdir do |dir|
764
+ Dir.chdir(dir) do
765
+ FileUtils.mkdir_p('screenshots/baselines/chrome')
766
+ File.binwrite('screenshots/baselines/chrome/home.png', ChunkyPNG::Image.new(4, 4, ChunkyPNG::Color::WHITE).to_blob)
767
+ black = ChunkyPNG::Image.new(4, 4, ChunkyPNG::Color::BLACK).to_blob
768
+ host = host_with(black)
769
+ expect { host.match_screenshot('home', timeout: 0) }.to raise_error(ScreenshotHelper::Mismatch)
770
+ end
771
+ end
772
+ expect(attached.map(&:first)).to eq(%w[comparison:home:expected comparison:home:actual comparison:home:diff])
773
+ expect(recorded.first).to include(status: 'failed', diff_pixels: 16)
774
+ end
775
+ end
776
+ end