ruby_raider 2.0.0 → 3.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 (224) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +58 -0
  3. data/.github/workflows/steep.yml +21 -0
  4. data/.gitignore +1 -1
  5. data/.reek.yml +46 -4
  6. data/.ruby-version +1 -1
  7. data/README.md +138 -77
  8. data/Steepfile +22 -0
  9. data/assets/ruby_raider_logo.svg +51 -0
  10. data/lib/adopter/adopt_menu.rb +11 -15
  11. data/lib/adopter/converters/base_converter.rb +1 -2
  12. data/lib/adopter/converters/identity_converter.rb +3 -6
  13. data/lib/adopter/migration_plan.rb +0 -1
  14. data/lib/adopter/plan_builder.rb +2 -5
  15. data/lib/adopter/project_analyzer.rb +1 -5
  16. data/lib/adopter/project_detector.rb +3 -5
  17. data/lib/commands/adopt_commands.rb +0 -1
  18. data/lib/commands/plugin_commands.rb +0 -2
  19. data/lib/commands/scaffolding_commands.rb +220 -37
  20. data/lib/commands/utility_commands.rb +82 -2
  21. data/lib/generators/automation/automation_generator.rb +0 -7
  22. data/lib/generators/automation/templates/partials/element.tt +1 -1
  23. data/lib/generators/automation/templates/partials/initialize_selector.tt +0 -7
  24. data/lib/generators/automation/templates/partials/url_methods.tt +0 -1
  25. data/lib/generators/common_generator.rb +12 -0
  26. data/lib/generators/cucumber/cucumber_generator.rb +36 -0
  27. data/lib/generators/cucumber/templates/accessibility_feature.tt +5 -0
  28. data/lib/generators/cucumber/templates/accessibility_steps.tt +21 -0
  29. data/lib/generators/cucumber/templates/cucumber.tt +8 -1
  30. data/lib/generators/cucumber/templates/feature.tt +0 -4
  31. data/lib/generators/cucumber/templates/partials/appium_env.tt +5 -0
  32. data/lib/generators/cucumber/templates/partials/capybara_env.tt +19 -1
  33. data/lib/generators/cucumber/templates/partials/driver_world.tt +1 -4
  34. data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -35
  35. data/lib/generators/cucumber/templates/partials/watir_env.tt +20 -1
  36. data/lib/generators/cucumber/templates/partials/web_steps.tt +6 -12
  37. data/lib/generators/cucumber/templates/performance_feature.tt +5 -0
  38. data/lib/generators/cucumber/templates/performance_steps.tt +17 -0
  39. data/lib/generators/cucumber/templates/visual_feature.tt +5 -0
  40. data/lib/generators/cucumber/templates/visual_steps.tt +19 -0
  41. data/lib/generators/generator.rb +38 -7
  42. data/lib/generators/helper_generator.rb +24 -7
  43. data/lib/generators/infrastructure/templates/github.tt +1 -1
  44. data/lib/generators/infrastructure/templates/github_appium.tt +2 -2
  45. data/lib/generators/infrastructure/templates/gitlab.tt +1 -1
  46. data/lib/generators/invoke_generators.rb +42 -9
  47. data/lib/generators/menu_generator.rb +120 -11
  48. data/lib/generators/minitest/minitest_generator.rb +16 -4
  49. data/lib/generators/minitest/templates/accessibility_test.tt +26 -0
  50. data/lib/generators/minitest/templates/performance_test.tt +18 -0
  51. data/lib/generators/minitest/templates/test.tt +5 -34
  52. data/lib/generators/minitest/templates/visual_test.tt +23 -0
  53. data/lib/generators/rspec/rspec_generator.rb +16 -4
  54. data/lib/generators/rspec/templates/accessibility_spec.tt +25 -0
  55. data/lib/generators/rspec/templates/performance_spec.tt +18 -0
  56. data/lib/generators/rspec/templates/spec.tt +5 -35
  57. data/lib/generators/rspec/templates/visual_spec.tt +20 -0
  58. data/lib/generators/template_renderer/partial_cache.rb +11 -1
  59. data/lib/generators/template_renderer/partial_resolver.rb +17 -10
  60. data/lib/generators/template_renderer.rb +17 -1
  61. data/lib/generators/templates/common/gemfile.tt +21 -6
  62. data/lib/generators/templates/common/git_ignore.tt +6 -1
  63. data/lib/generators/templates/common/partials/mobile_config.tt +5 -1
  64. data/lib/generators/templates/common/partials/web_config.tt +16 -7
  65. data/lib/generators/templates/common/rakefile.tt +36 -0
  66. data/lib/generators/templates/common/read_me.tt +41 -91
  67. data/lib/generators/templates/common/rspec.tt +3 -0
  68. data/lib/generators/templates/common/ruby_version.tt +1 -0
  69. data/lib/generators/templates/helpers/allure_helper.tt +11 -0
  70. data/lib/generators/templates/helpers/browser_helper.tt +12 -2
  71. data/lib/generators/templates/helpers/capybara_helper.tt +5 -1
  72. data/lib/generators/templates/helpers/debug_helper.tt +190 -0
  73. data/lib/generators/templates/helpers/driver_helper.tt +2 -10
  74. data/lib/generators/templates/helpers/partials/appium_driver.tt +0 -2
  75. data/lib/generators/templates/helpers/partials/debug_diagnostics.tt +7 -0
  76. data/lib/generators/templates/helpers/partials/debug_start.tt +7 -0
  77. data/lib/generators/templates/helpers/partials/driver_and_options.tt +1 -3
  78. data/lib/generators/templates/helpers/partials/selenium_driver.tt +8 -7
  79. data/lib/generators/templates/helpers/partials/video_start.tt +9 -0
  80. data/lib/generators/templates/helpers/partials/video_stop.tt +4 -0
  81. data/lib/generators/templates/helpers/performance_helper.tt +57 -0
  82. data/lib/generators/templates/helpers/spec_helper.tt +57 -8
  83. data/lib/generators/templates/helpers/test_helper.tt +69 -1
  84. data/lib/generators/templates/helpers/video_helper.tt +270 -0
  85. data/lib/generators/templates/helpers/visual_helper.tt +39 -46
  86. data/lib/llm/client.rb +79 -0
  87. data/lib/llm/config.rb +57 -0
  88. data/lib/llm/prompts.rb +84 -0
  89. data/lib/llm/provider.rb +27 -0
  90. data/lib/llm/providers/anthropic_provider.rb +43 -0
  91. data/lib/llm/providers/ollama_provider.rb +56 -0
  92. data/lib/llm/providers/openai_provider.rb +42 -0
  93. data/lib/llm/response_parser.rb +67 -0
  94. data/lib/plugin/plugin.rb +22 -20
  95. data/lib/plugin/plugin_exposer.rb +16 -38
  96. data/lib/ruby_raider.rb +47 -12
  97. data/lib/scaffolding/crud_generator.rb +94 -0
  98. data/lib/scaffolding/dry_run_presenter.rb +16 -0
  99. data/lib/scaffolding/name_normalizer.rb +63 -0
  100. data/lib/scaffolding/page_introspector.rb +45 -0
  101. data/lib/scaffolding/project_detector.rb +72 -0
  102. data/lib/scaffolding/scaffold_menu.rb +103 -0
  103. data/lib/scaffolding/scaffolding.rb +158 -11
  104. data/lib/scaffolding/templates/component.tt +30 -0
  105. data/lib/scaffolding/templates/feature.tt +4 -4
  106. data/lib/scaffolding/templates/helper.tt +15 -1
  107. data/lib/scaffolding/templates/page_from_url.tt +75 -0
  108. data/lib/scaffolding/templates/page_object.tt +50 -1
  109. data/lib/scaffolding/templates/spec.tt +33 -2
  110. data/lib/scaffolding/templates/spec_from_page.tt +31 -0
  111. data/lib/scaffolding/templates/spec_from_url.tt +46 -0
  112. data/lib/scaffolding/templates/steps.tt +17 -5
  113. data/lib/scaffolding/url_analyzer.rb +179 -0
  114. data/lib/utilities/desktop_downloader.rb +177 -0
  115. data/lib/utilities/logo.rb +83 -0
  116. data/lib/utilities/utilities.rb +53 -20
  117. data/lib/version +1 -1
  118. data/ruby_raider.gemspec +1 -0
  119. data/sig/adopter/adopt_menu.rbs +25 -0
  120. data/sig/adopter/converters/base_converter.rbs +23 -0
  121. data/sig/adopter/converters/identity_converter.rbs +16 -0
  122. data/sig/adopter/migration_plan.rbs +34 -0
  123. data/sig/adopter/migrator.rbs +21 -0
  124. data/sig/adopter/plan_builder.rbs +38 -0
  125. data/sig/adopter/project_analyzer.rbs +39 -0
  126. data/sig/adopter/project_detector.rbs +26 -0
  127. data/sig/commands/adopt_commands.rbs +8 -0
  128. data/sig/commands/loaded_commands.rbs +5 -0
  129. data/sig/commands/plugin_commands.rbs +9 -0
  130. data/sig/commands/scaffolding_commands.rbs +28 -0
  131. data/sig/commands/utility_commands.rbs +21 -0
  132. data/sig/generators/automation/automation_generator.rbs +20 -0
  133. data/sig/generators/common_generator.rbs +12 -0
  134. data/sig/generators/cucumber/cucumber_generator.rbs +16 -0
  135. data/sig/generators/generator.rbs +40 -0
  136. data/sig/generators/helper_generator.rbs +18 -0
  137. data/sig/generators/infrastructure/github_generator.rbs +5 -0
  138. data/sig/generators/infrastructure/gitlab_generator.rbs +4 -0
  139. data/sig/generators/invoke_generators.rbs +10 -0
  140. data/sig/generators/menu_generator.rbs +29 -0
  141. data/sig/generators/minitest/minitest_generator.rbs +8 -0
  142. data/sig/generators/rspec/rspec_generator.rbs +8 -0
  143. data/sig/generators/template_renderer/partial_cache.rbs +20 -0
  144. data/sig/generators/template_renderer/partial_resolver.rbs +20 -0
  145. data/sig/generators/template_renderer/template_error.rbs +19 -0
  146. data/sig/generators/template_renderer.rbs +10 -0
  147. data/sig/llm/client.rbs +15 -0
  148. data/sig/llm/config.rbs +20 -0
  149. data/sig/llm/prompts.rbs +8 -0
  150. data/sig/llm/provider.rbs +12 -0
  151. data/sig/llm/providers/anthropic_provider.rbs +16 -0
  152. data/sig/llm/providers/ollama_provider.rbs +18 -0
  153. data/sig/llm/providers/openai_provider.rbs +16 -0
  154. data/sig/llm/response_parser.rbs +13 -0
  155. data/sig/plugin/plugin.rbs +24 -0
  156. data/sig/plugin/plugin_exposer.rbs +20 -0
  157. data/sig/ruby_raider.rbs +15 -0
  158. data/sig/scaffolding/crud_generator.rbs +16 -0
  159. data/sig/scaffolding/dry_run_presenter.rbs +4 -0
  160. data/sig/scaffolding/name_normalizer.rbs +17 -0
  161. data/sig/scaffolding/page_introspector.rbs +14 -0
  162. data/sig/scaffolding/project_detector.rbs +14 -0
  163. data/sig/scaffolding/scaffold_menu.rbs +18 -0
  164. data/sig/scaffolding/scaffolding.rbs +55 -0
  165. data/sig/scaffolding/url_analyzer.rbs +28 -0
  166. data/sig/utilities/desktop_downloader.rbs +23 -0
  167. data/sig/utilities/logger.rbs +13 -0
  168. data/sig/utilities/logo.rbs +16 -0
  169. data/sig/utilities/utilities.rbs +30 -0
  170. data/sig/vendor/thor.rbs +34 -0
  171. data/sig/vendor/tty_prompt.rbs +15 -0
  172. data/spec/adopter/adopt_menu_spec.rb +12 -12
  173. data/spec/adopter/migration_plan_spec.rb +1 -1
  174. data/spec/adopter/migrator_spec.rb +2 -2
  175. data/spec/adopter/project_detector_spec.rb +1 -1
  176. data/spec/commands/raider_commands_spec.rb +129 -0
  177. data/spec/generators/generator_spec.rb +23 -0
  178. data/spec/integration/commands/scaffolding_commands_spec.rb +1 -1
  179. data/spec/integration/commands/utility_commands_spec.rb +23 -3
  180. data/spec/integration/content/ci_content_spec.rb +119 -0
  181. data/spec/integration/content/common_content_spec.rb +288 -0
  182. data/spec/integration/content/config_content_spec.rb +175 -0
  183. data/spec/integration/content/content_helper.rb +32 -0
  184. data/spec/integration/content/gemfile_content_spec.rb +209 -0
  185. data/spec/integration/content/helper_content_spec.rb +485 -0
  186. data/spec/integration/content/page_content_spec.rb +259 -0
  187. data/spec/integration/content/reporter_content_spec.rb +236 -0
  188. data/spec/integration/content/skip_flags_content_spec.rb +206 -0
  189. data/spec/integration/content/syntax_validation_spec.rb +30 -0
  190. data/spec/integration/content/test_content_spec.rb +266 -0
  191. data/spec/integration/end_to_end_features_spec.rb +690 -0
  192. data/spec/integration/end_to_end_spec.rb +52 -16
  193. data/spec/integration/generators/automation_generator_spec.rb +0 -12
  194. data/spec/integration/generators/axe_addon_spec.rb +150 -0
  195. data/spec/integration/generators/common_generator_spec.rb +12 -13
  196. data/spec/integration/generators/config_features_spec.rb +155 -0
  197. data/spec/integration/generators/debug_helper_spec.rb +68 -0
  198. data/spec/integration/generators/helpers_generator_spec.rb +0 -12
  199. data/spec/integration/generators/lighthouse_addon_spec.rb +132 -0
  200. data/spec/integration/generators/minitest_generator_spec.rb +0 -6
  201. data/spec/integration/generators/reporter_spec.rb +159 -0
  202. data/spec/integration/generators/skip_flags_spec.rb +134 -0
  203. data/spec/integration/generators/visual_addon_spec.rb +148 -0
  204. data/spec/integration/settings_helper.rb +0 -3
  205. data/spec/integration/spec_helper.rb +30 -13
  206. data/spec/llm/client_spec.rb +79 -0
  207. data/spec/llm/config_spec.rb +92 -0
  208. data/spec/llm/prompts_spec.rb +49 -0
  209. data/spec/llm/response_parser_spec.rb +92 -0
  210. data/spec/menus/adopter_adopt_menu_spec.rb +97 -0
  211. data/spec/menus/menu_generator_spec.rb +263 -0
  212. data/spec/scaffolding/name_normalizer_spec.rb +113 -0
  213. data/spec/scaffolding/page_introspector_spec.rb +82 -0
  214. data/spec/scaffolding/scaffold_project_detector_spec.rb +104 -0
  215. data/spec/scaffolding/scaffolding_features_spec.rb +311 -0
  216. data/spec/scaffolding/url_analyzer_spec.rb +110 -0
  217. data/spec/system/adopt_matrix_spec.rb +537 -0
  218. data/spec/system/adopt_spec.rb +225 -0
  219. data/spec/system/support/system_test_helper.rb +0 -2
  220. data/spec/utilities/desktop_downloader_spec.rb +92 -0
  221. metadata +150 -5
  222. data/lib/generators/automation/templates/visual_options.tt +0 -16
  223. data/lib/generators/templates/helpers/partials/axe_driver.tt +0 -10
  224. data/lib/generators/templates/helpers/visual_spec_helper.tt +0 -35
@@ -1,7 +1,9 @@
1
1
  def create_driver(*opts)
2
2
  @config = YAML.load_file('config/config.yml')
3
3
  browser = @config['browser']
4
- Selenium::WebDriver.for(browser.to_sym, options: create_webdriver_options(*opts))
4
+ driver = Selenium::WebDriver.for(browser.to_sym, options: create_webdriver_options(*opts))
5
+ driver.manage.timeouts.implicit_wait = @config.fetch('timeout', 10)
6
+ driver
5
7
  end
6
8
 
7
9
  def browser_arguments(*opts)
@@ -10,16 +12,15 @@
10
12
  args
11
13
  end
12
14
 
13
- def driver_options
14
- @config['driver_options']
15
- end
16
-
17
- # :reek:FeatureEnvy
18
15
  def create_webdriver_options(*opts)
19
16
  load_browser = @config['browser'].to_s
20
17
  browser = load_browser == 'ie' ? load_browser.upcase : load_browser.capitalize
21
18
  options = "Selenium::WebDriver::#{browser}::Options".constantize.new
22
19
  browser_arguments(*opts).each { |arg| options.add_argument(arg) }
23
- driver_options.each { |opt| options.add_option(opt.first, opt.last) }
20
+ debug_cfg = @config['debug'] || {}
21
+ if %w[chrome edge msedge].include?(@config['browser'].to_s.downcase) &&
22
+ (debug_cfg.fetch('enabled', false) || ENV['DEBUG']&.downcase == 'true')
23
+ options.add_option('goog:loggingPrefs', { browser: 'ALL', performance: 'ALL' })
24
+ end
24
25
  options
25
26
  end
@@ -0,0 +1,9 @@
1
+ <% if capybara? %>
2
+ @video_recorder = VideoHelper.recorder_for(Capybara.current_session.driver)
3
+ <% elsif selenium_based? %>
4
+ @video_recorder = VideoHelper.recorder_for(driver)
5
+ <% elsif watir? %>
6
+ @video_recorder = VideoHelper.recorder_for(browser)
7
+ <% else %>
8
+ @video_recorder = VideoHelper.recorder_for(driver)
9
+ <% end %>
@@ -0,0 +1,4 @@
1
+ video_file = @video_recorder&.stop
2
+ <% if allure_reporter? %>
3
+ AllureHelper.add_video(example_name, video_file)
4
+ <% end %>
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require 'yaml'
7
+
8
+ module PerformanceHelper
9
+ REPORTS_DIR = 'lighthouse_reports'
10
+
11
+ def run_lighthouse_audit(url, categories: %w[performance accessibility best-practices seo])
12
+ FileUtils.mkdir_p(REPORTS_DIR)
13
+
14
+ report_path = File.join(REPORTS_DIR, "report_#{Time.now.to_i}.json")
15
+ category_flags = categories.map { |c| "--only-categories=#{c}" }.join(' ')
16
+
17
+ config_timeout = if File.exist?('config/config.yml')
18
+ YAML.load_file('config/config.yml').fetch('timeout', 10)
19
+ else
20
+ 10
21
+ end
22
+ max_wait = config_timeout * 1000
23
+
24
+ command = "lighthouse #{url} --output=json --output-path=#{report_path} " \
25
+ "--chrome-flags=\"--headless --no-sandbox\" #{category_flags} " \
26
+ "--max-wait-for-load=#{max_wait} --quiet"
27
+
28
+ stdout, stderr, status = Open3.capture3(command)
29
+
30
+ unless status.success?
31
+ return { status: :error, message: "Lighthouse failed: #{stderr.strip.empty? ? stdout : stderr}" }
32
+ end
33
+
34
+ report = JSON.parse(File.read(report_path))
35
+ scores = extract_scores(report)
36
+
37
+ { status: :success, scores:, report_path: }
38
+ end
39
+
40
+ def assert_performance_above(url, threshold: 0.8, categories: %w[performance])
41
+ result = run_lighthouse_audit(url, categories:)
42
+ return result if result[:status] == :error
43
+
44
+ passed = result[:scores].all? { |_category, score| score >= threshold }
45
+
46
+ { status: passed ? :pass : :fail, scores: result[:scores], passed:, threshold:,
47
+ report_path: result[:report_path] }
48
+ end
49
+
50
+ private
51
+
52
+ def extract_scores(report)
53
+ report.fetch('categories', {}).each_with_object({}) do |(key, data), scores|
54
+ scores[key] = data['score']
55
+ end
56
+ end
57
+ end
@@ -1,11 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- <%- if axe? %>
3
+ <%- if axe_addon? %>
4
4
  require 'axe-rspec'
5
5
  <%- end -%>
6
6
  require 'rspec'
7
+ require 'rspec/retry'
7
8
  require 'tmpdir'
9
+ <%- if allure_reporter? -%>
8
10
  require_relative 'allure_helper'
11
+ <%- end -%>
12
+ <%- unless skip_video? -%>
13
+ require_relative 'video_helper'
14
+ <%- end -%>
15
+ require_relative 'debug_helper'
9
16
  <%- if capybara? -%>
10
17
  require_relative 'capybara_helper'
11
18
  <%- elsif watir? -%>
@@ -16,9 +23,23 @@ require_relative 'driver_helper'
16
23
 
17
24
  module SpecHelper
18
25
 
26
+ <%- if allure_reporter? -%>
19
27
  AllureHelper.configure
28
+ <%- end -%>
20
29
  RSpec.configure do |config|
30
+ config.example_status_persistence_file_path = 'spec/examples.txt'
31
+ config.verbose_retry = true
32
+ config.display_try_failure_messages = true
33
+ config.default_retry_count = ENV.fetch('RETRY_COUNT', 0).to_i
34
+ <%- if allure_reporter? -%>
21
35
  config.formatter = AllureHelper.formatter
36
+ <%- end -%>
37
+ <%- if junit_reporter? -%>
38
+ config.add_formatter('RspecJunitFormatter', 'results/junit.xml')
39
+ <%- end -%>
40
+ <%- if json_reporter? -%>
41
+ config.add_formatter('json', 'results/results.json')
42
+ <%- end -%>
22
43
  <%- if capybara? -%>
23
44
  config.include(Capybara::DSL)
24
45
  CapybaraHelper.configure
@@ -30,26 +51,54 @@ module SpecHelper
30
51
  <%- if mobile? -%>
31
52
  config.before(:each) do
32
53
  driver.start_driver
33
- end
34
54
  <%- elsif capybara? -%>
35
55
  config.before(:each) do
36
- Capybara.current_session.driver.browser.manage.window.maximize
37
- end
56
+ viewport = YAML.load_file('config/config.yml')['viewport']
57
+ if viewport
58
+ Capybara.current_session.driver.browser.manage.window.resize_to(viewport['width'], viewport['height'])
59
+ else
60
+ Capybara.current_session.driver.browser.manage.window.maximize
61
+ end
38
62
  <%- elsif selenium_based? -%>
39
63
  config.before(:each) do
40
- driver.manage.window.maximize
41
- end
64
+ viewport = YAML.load_file('config/config.yml')['viewport']
65
+ if viewport
66
+ driver.manage.window.resize_to(viewport['width'], viewport['height'])
67
+ else
68
+ driver.manage.window.maximize
69
+ end
42
70
  <%- elsif watir? -%>
43
71
  config.before(:each) do
44
- browser.window.maximize
45
- end
72
+ viewport = YAML.load_file('config/config.yml')['viewport']
73
+ if viewport
74
+ browser.window.resize_to(viewport['width'], viewport['height'])
75
+ else
76
+ browser.window.maximize
77
+ end
46
78
  <%- end -%>
79
+ <%- unless skip_video? -%>
80
+ <%= partial('video_start', strip: true) %>
81
+ @video_recorder&.start(self.class.description)
82
+ <%- end -%>
83
+ <%= partial('debug_start', strip: true) %>
84
+ @debug_action_logger = DebugHelper.action_logger_for(self.class.description)
85
+ end
47
86
 
48
87
  config.after(:each) do |example|
49
88
  example_name = example.description
89
+ if example.exception
90
+ @_exception = example.exception
91
+ <%= partial('debug_diagnostics', strip: true) %>
92
+ end
93
+ @debug_action_logger&.close
94
+ <%- unless skip_video? -%>
95
+ <%= partial('video_stop', strip: true) %>
96
+ <%- end -%>
50
97
  Dir.mktmpdir do |temp_folder|
51
98
  <%= partial('screenshot', strip: true) %>
99
+ <%- if allure_reporter? -%>
52
100
  AllureHelper.add_screenshot(example_name, screenshot)
101
+ <%- end -%>
53
102
  end
54
103
  <%= partial('quit_driver', strip: true) %>
55
104
  end
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- <%- if axe? %>
3
+ <%- if axe_addon? %>
4
4
  require 'axe-minitest'
5
5
  <%- end -%>
6
6
  require 'minitest/autorun'
7
7
  require 'tmpdir'
8
+ <%- if allure_reporter? -%>
8
9
  require_relative 'allure_helper'
10
+ <%- end -%>
11
+ <%- unless skip_video? -%>
12
+ require_relative 'video_helper'
13
+ <%- end -%>
14
+ require_relative 'debug_helper'
9
15
  <%- if watir? -%>
10
16
  require_relative 'browser_helper'
11
17
  <%- elsif capybara? -%>
@@ -14,13 +20,75 @@ require_relative 'capybara_helper'
14
20
  require_relative 'driver_helper'
15
21
  <%- end -%>
16
22
 
23
+ <%- if allure_reporter? -%>
17
24
  AllureHelper.configure
25
+ <%- end -%>
18
26
  <%- if capybara? -%>
19
27
  CapybaraHelper.configure
20
28
  <%- end -%>
29
+ <%- if json_reporter? -%>
30
+ require 'minitest/reporters'
31
+ Minitest::Reporters.use! [Minitest::Reporters::JsonReporter.new('results/results.json')]
32
+ <%- end -%>
21
33
 
22
34
  module TestHelper
23
35
  <% if watir? %>include BrowserHelper<% elsif capybara? %>include Capybara::DSL<% else %>include DriverHelper<% end %>
36
+
37
+ def setup
38
+ super
39
+ <%- if mobile? -%>
40
+ driver.start_driver
41
+ <%- elsif capybara? -%>
42
+ viewport = YAML.load_file('config/config.yml')['viewport']
43
+ if viewport
44
+ Capybara.current_session.driver.browser.manage.window.resize_to(viewport['width'], viewport['height'])
45
+ else
46
+ Capybara.current_session.driver.browser.manage.window.maximize
47
+ end
48
+ <%- elsif watir? -%>
49
+ viewport = YAML.load_file('config/config.yml')['viewport']
50
+ if viewport
51
+ browser.window.resize_to(viewport['width'], viewport['height'])
52
+ else
53
+ browser.window.maximize
54
+ end
55
+ <%- else -%>
56
+ viewport = YAML.load_file('config/config.yml')['viewport']
57
+ if viewport
58
+ driver.manage.window.resize_to(viewport['width'], viewport['height'])
59
+ else
60
+ driver.manage.window.maximize
61
+ end
62
+ <%- end -%>
63
+ <%- unless skip_video? -%>
64
+ <%= partial('video_start', strip: true) %>
65
+ @video_recorder&.start(self.class.name)
66
+ <%- end -%>
67
+ <%= partial('debug_start', strip: true) %>
68
+ @debug_action_logger = DebugHelper.action_logger_for(self.class.name)
69
+ end
70
+
71
+ def teardown
72
+ example_name = name
73
+ unless passed?
74
+ <%= partial('debug_diagnostics', strip: true) %>
75
+ end
76
+ @debug_action_logger&.close
77
+ <%- unless skip_video? -%>
78
+ video_file = @video_recorder&.stop
79
+ <%- if allure_reporter? -%>
80
+ AllureHelper.add_video(example_name, video_file)
81
+ <%- end -%>
82
+ <%- end -%>
83
+ Dir.mktmpdir do |temp_folder|
84
+ <%= partial('screenshot', strip: true) %>
85
+ <%- if allure_reporter? -%>
86
+ AllureHelper.add_screenshot(example_name, screenshot)
87
+ <%- end -%>
88
+ end
89
+ <%= partial('quit_driver', strip: true) %>
90
+ super
91
+ end
24
92
  end
25
93
 
26
94
  Minitest::Test.include(TestHelper)
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ require 'json'
6
+ require 'base64'
7
+
8
+ module VideoHelper
9
+ VIDEO_DIR = 'videos'
10
+
11
+ module_function
12
+
13
+ def config
14
+ @config ||= begin
15
+ yml = YAML.load_file('config/config.yml')
16
+ yml['video'] || {}
17
+ rescue StandardError
18
+ {}
19
+ end
20
+ end
21
+
22
+ def enabled?
23
+ config.fetch('enabled', false)
24
+ end
25
+
26
+ def recorder_for(driver_or_browser)
27
+ return NullRecorder.new unless enabled?
28
+
29
+ strategy = config.fetch('strategy', 'auto')
30
+ case strategy
31
+ when 'cdp' then CdpRecorder.new(resolve_selenium_driver(driver_or_browser))
32
+ when 'screen' then ScreenCaptureRecorder.new
33
+ <%- if mobile? -%>
34
+ when 'appium' then AppiumRecorder.new(driver_or_browser)
35
+ <%- end -%>
36
+ when 'auto' then auto_detect(driver_or_browser)
37
+ else NullRecorder.new
38
+ end
39
+ end
40
+
41
+ def auto_detect(driver_or_browser)
42
+ sel_driver = resolve_selenium_driver(driver_or_browser)
43
+ <%- if mobile? -%>
44
+ return AppiumRecorder.new(driver_or_browser) if defined?(Appium::Driver)
45
+ <%- end -%>
46
+ return CdpRecorder.new(sel_driver) if chrome_or_edge?(sel_driver)
47
+
48
+ if ffmpeg_available?
49
+ ScreenCaptureRecorder.new
50
+ else
51
+ NullRecorder.new
52
+ end
53
+ end
54
+
55
+ def resolve_selenium_driver(obj)
56
+ if obj.respond_to?(:driver) && !obj.is_a?(Selenium::WebDriver::Driver)
57
+ obj.driver
58
+ elsif obj.respond_to?(:browser) && obj.browser.respond_to?(:driver)
59
+ obj.browser.driver
60
+ else
61
+ obj
62
+ end
63
+ rescue StandardError
64
+ obj
65
+ end
66
+
67
+ def chrome_or_edge?(driver)
68
+ browser_name = driver.capabilities[:browser_name].to_s.downcase
69
+ %w[chrome chromium msedge edge].any? { |b| browser_name.include?(b) }
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ def ffmpeg_available?
75
+ system('ffmpeg -version > /dev/null 2>&1') ||
76
+ system('ffmpeg -version > NUL 2>&1')
77
+ end
78
+
79
+ def ensure_video_dir
80
+ FileUtils.mkdir_p(VIDEO_DIR)
81
+ end
82
+
83
+ # --- CDP Recorder: Chrome/Edge, works headless ---
84
+
85
+ class CdpRecorder
86
+ def initialize(driver)
87
+ @driver = driver
88
+ @frames = []
89
+ @recording = false
90
+ end
91
+
92
+ def start(test_name)
93
+ @test_name = sanitize(test_name)
94
+ @frames = []
95
+ @frame_index = 0
96
+ @recording = true
97
+ @driver.execute_cdp('Page.startScreencast',
98
+ format: 'jpeg', quality: 60,
99
+ maxWidth: 1280, maxHeight: 720,
100
+ everyNthFrame: 2)
101
+ start_frame_collector
102
+ rescue StandardError => e
103
+ warn "[video] CDP recording failed to start: #{e.message}"
104
+ @recording = false
105
+ end
106
+
107
+ def stop
108
+ return nil unless @recording
109
+
110
+ @recording = false
111
+ @driver.execute_cdp('Page.stopScreencast')
112
+ sleep 0.2 # allow final frames to arrive
113
+ assemble_video
114
+ rescue StandardError => e
115
+ warn "[video] CDP recording failed to stop: #{e.message}"
116
+ nil
117
+ end
118
+
119
+ private
120
+
121
+ def start_frame_collector
122
+ @collector = Thread.new do
123
+ while @recording
124
+ begin
125
+ collect_frame
126
+ rescue StandardError
127
+ sleep 0.1
128
+ end
129
+ sleep 0.05
130
+ end
131
+ end
132
+ end
133
+
134
+ def collect_frame
135
+ # CDP screencast events are handled by polling via execute_cdp
136
+ # We capture screenshots at intervals as a reliable alternative
137
+ VideoHelper.ensure_video_dir
138
+ frame_path = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}_frame_#{format('%04d', @frame_index)}.jpg")
139
+ @driver.save_screenshot(frame_path)
140
+ @frames << frame_path
141
+ @frame_index += 1
142
+ end
143
+
144
+ def assemble_video
145
+ return nil if @frames.empty?
146
+
147
+ @collector&.join(2)
148
+ VideoHelper.ensure_video_dir
149
+ output = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}.mp4")
150
+ pattern = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}_frame_%04d.jpg")
151
+
152
+ system("ffmpeg -y -framerate 10 -i \"#{pattern}\" -c:v libx264 -pix_fmt yuv420p " \
153
+ "-movflags +faststart -loglevel error \"#{output}\"")
154
+
155
+ cleanup_frames
156
+ File.exist?(output) ? output : nil
157
+ end
158
+
159
+ def cleanup_frames
160
+ @frames.each { |f| FileUtils.rm_f(f) }
161
+ end
162
+
163
+ def sanitize(name)
164
+ name.gsub(/[^a-zA-Z0-9_-]/, '_')[0..80]
165
+ end
166
+ end
167
+
168
+ # --- Screen Capture Recorder: Any browser, requires FFmpeg + display ---
169
+
170
+ class ScreenCaptureRecorder
171
+ def initialize
172
+ @recording = false
173
+ end
174
+
175
+ def start(test_name)
176
+ @test_name = sanitize(test_name)
177
+ VideoHelper.ensure_video_dir
178
+ @output = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}.mp4")
179
+ @recording = true
180
+
181
+ input = screen_input
182
+ @pid = spawn("ffmpeg -y #{input} -r 15 -c:v libx264 -pix_fmt yuv420p " \
183
+ "-loglevel error \"#{@output}\"",
184
+ %i[out err] => '/dev/null')
185
+ rescue StandardError => e
186
+ warn "[video] Screen recording failed to start: #{e.message}"
187
+ @recording = false
188
+ end
189
+
190
+ def stop
191
+ return nil unless @recording
192
+
193
+ @recording = false
194
+ Process.kill('INT', @pid)
195
+ Process.wait(@pid)
196
+ File.exist?(@output) ? @output : nil
197
+ rescue StandardError => e
198
+ warn "[video] Screen recording failed to stop: #{e.message}"
199
+ nil
200
+ end
201
+
202
+ private
203
+
204
+ def screen_input
205
+ case RbConfig::CONFIG['host_os']
206
+ when /darwin/i
207
+ '-f avfoundation -i "1:none"'
208
+ when /linux/i
209
+ display = ENV.fetch('DISPLAY', ':99')
210
+ "-f x11grab -i #{display}"
211
+ when /mswin|mingw|cygwin/i
212
+ '-f gdigrab -i desktop'
213
+ else
214
+ '-f x11grab -i :0'
215
+ end
216
+ end
217
+
218
+ def sanitize(name)
219
+ name.gsub(/[^a-zA-Z0-9_-]/, '_')[0..80]
220
+ end
221
+ end
222
+
223
+ <%- if mobile? -%>
224
+ # --- Appium Recorder: Mobile, uses native API ---
225
+
226
+ class AppiumRecorder
227
+ def initialize(driver)
228
+ @driver = driver
229
+ @recording = false
230
+ end
231
+
232
+ def start(test_name)
233
+ @test_name = sanitize(test_name)
234
+ @recording = true
235
+ @driver.start_recording_screen
236
+ rescue StandardError => e
237
+ warn "[video] Appium recording failed to start: #{e.message}"
238
+ @recording = false
239
+ end
240
+
241
+ def stop
242
+ return nil unless @recording
243
+
244
+ @recording = false
245
+ video_base64 = @driver.stop_recording_screen
246
+ VideoHelper.ensure_video_dir
247
+ output = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}.mp4")
248
+ File.write(output, Base64.decode64(video_base64), mode: 'wb')
249
+ output
250
+ rescue StandardError => e
251
+ warn "[video] Appium recording failed to stop: #{e.message}"
252
+ nil
253
+ end
254
+
255
+ private
256
+
257
+ def sanitize(name)
258
+ name.gsub(/[^a-zA-Z0-9_-]/, '_')[0..80]
259
+ end
260
+ end
261
+
262
+ <%- end -%>
263
+ # --- Null Recorder: Fallback when recording unavailable ---
264
+
265
+ class NullRecorder
266
+ def start(_test_name) = nil
267
+
268
+ def stop = nil
269
+ end
270
+ end
@@ -1,65 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/inflector'
4
- require 'eyes_selenium'
5
- require 'yaml'
3
+ require 'chunky_png'
4
+ require 'fileutils'
6
5
 
7
6
  module VisualHelper
8
- attr_reader :eyes
7
+ BASELINE_DIR = 'visual_baselines'
8
+ DIFF_DIR = 'visual_diffs'
9
9
 
10
- SELENIUM = Applitools::Selenium
11
- VISUAL_GRID = SELENIUM::VisualGridRunner
12
- EYES = SELENIUM::Eyes
13
- TARGET = SELENIUM::Target
14
- BATCHINFO = Applitools::BatchInfo
15
- REGTANGLE_SIZE = Applitools::RectangleSize
10
+ def compare_screenshot(name, screenshot_path, threshold: 0.01)
11
+ FileUtils.mkdir_p(BASELINE_DIR)
12
+ FileUtils.mkdir_p(DIFF_DIR)
16
13
 
17
- def create_grid_runner(concurrency = 1)
18
- VISUAL_GRID.new(concurrency)
19
- end
14
+ baseline_path = File.join(BASELINE_DIR, "#{name}.png")
20
15
 
21
- def create_eyes(grid_runner)
22
- EYES.new(runner: grid_runner)
16
+ unless File.exist?(baseline_path)
17
+ FileUtils.cp(screenshot_path, baseline_path)
18
+ return { status: :baseline_created, path: baseline_path }
23
19
  end
24
20
 
25
- def check_page(page)
26
- page = format_page(page)
27
- @eyes.check(page, TARGET.window.fully)
28
- end
21
+ baseline = ChunkyPNG::Image.from_file(baseline_path)
22
+ current = ChunkyPNG::Image.from_file(screenshot_path)
29
23
 
30
- def configure_eyes(eyes, options = nil)
31
- options ||= YAML.load_file('config/options.yml')
24
+ diff_pixels = 0
25
+ total_pixels = baseline.width * baseline.height
32
26
 
33
- eyes.configure do |conf|
34
- # You can get your api key from the Applitools dashboard
35
- general_config(options, conf)
36
- add_browsers(options[:browsers], conf)
37
- add_devices(options[:devices], conf)
27
+ diff_image = ChunkyPNG::Image.new(baseline.width, baseline.height)
28
+
29
+ baseline.height.times do |y|
30
+ baseline.width.times do |x|
31
+ if pixel_match?(baseline[x, y], current[x, y])
32
+ diff_image[x, y] = ChunkyPNG::Color.rgba(0, 0, 0, 50)
33
+ else
34
+ diff_pixels += 1
35
+ diff_image[x, y] = ChunkyPNG::Color.rgb(255, 0, 0)
36
+ end
38
37
  end
39
38
  end
40
39
 
41
- def format_page(page)
42
- page.instance_of?(String) ? page : page.to_s
43
- end
40
+ diff_percentage = diff_pixels.to_f / total_pixels
44
41
 
45
- def add_browsers(browsers, conf)
46
- browsers.each do |browser|
47
- conf.add_browser(browser[:height], browser[:width], "BrowserType::#{browser[:name].upcase}".constantize)
48
- end
42
+ if diff_percentage > threshold
43
+ diff_path = File.join(DIFF_DIR, "#{name}_diff.png")
44
+ diff_image.save(diff_path)
45
+ { status: :mismatch, diff: diff_percentage, diff_path: }
46
+ else
47
+ { status: :match, diff: diff_percentage }
49
48
  end
49
+ end
50
50
 
51
- def add_devices(devices, conf)
52
- devices.each do |device|
53
- conf.add_device_emulation("Devices::#{device[:name]}".constantize,
54
- "Orientation::#{device[:orientation]}".constantize)
55
- end
56
- end
51
+ private
57
52
 
58
- def general_config(options, conf)
59
- conf.api_key = ENV['APPLITOOLS_API_KEY']
60
- conf.batch = BATCHINFO.new(options[:batch_name])
61
- conf.app_name = options[:app_name]
62
- conf.test_name = options[:test_name]
63
- conf.viewport_size = REGTANGLE_SIZE.new(options[:viewport_size][:height], options[:viewport_size][:width])
64
- end
53
+ def pixel_match?(pixel1, pixel2)
54
+ r1, g1, b1 = ChunkyPNG::Color.r(pixel1), ChunkyPNG::Color.g(pixel1), ChunkyPNG::Color.b(pixel1)
55
+ r2, g2, b2 = ChunkyPNG::Color.r(pixel2), ChunkyPNG::Color.g(pixel2), ChunkyPNG::Color.b(pixel2)
56
+ (r1 - r2).abs <= 5 && (g1 - g2).abs <= 5 && (b1 - b2).abs <= 5
57
+ end
65
58
  end