ruby_raider 3.12.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/README.md +2 -0
  4. data/lib/commands/utility_commands.rb +16 -0
  5. data/lib/generators/automation/templates/page.tt +8 -3
  6. data/lib/generators/automation/templates/partials/initialize_selector.tt +3 -3
  7. data/lib/generators/automation/templates/partials/url_methods.tt +3 -2
  8. data/lib/generators/automation/templates/partials/visit_method.tt +5 -5
  9. data/lib/generators/cucumber/cucumber_generator.rb +1 -1
  10. data/lib/generators/cucumber/templates/env.tt +3 -3
  11. data/lib/generators/cucumber/templates/partials/selenium_env.tt +65 -1
  12. data/lib/generators/cucumber/templates/steps.tt +2 -2
  13. data/lib/generators/cucumber/templates/user_factory.tt +2 -0
  14. data/lib/generators/cucumber/templates/world.tt +5 -3
  15. data/lib/generators/generator.rb +25 -12
  16. data/lib/generators/helper_generator.rb +7 -0
  17. data/lib/generators/rspec/templates/accessibility_spec.tt +10 -10
  18. data/lib/generators/rspec/templates/spec.tt +2 -1
  19. data/lib/generators/templates/common/gemfile.tt +9 -9
  20. data/lib/generators/templates/common/partials/web_config.tt +7 -0
  21. data/lib/generators/templates/common/read_me.tt +45 -0
  22. data/lib/generators/templates/helpers/appium_helper.tt +17 -15
  23. data/lib/generators/templates/helpers/browser_helper.tt +1 -2
  24. data/lib/generators/templates/helpers/driver_helper.tt +22 -0
  25. data/lib/generators/templates/helpers/partials/appium_driver.tt +1 -1
  26. data/lib/generators/templates/helpers/partials/selenium_driver.tt +11 -1
  27. data/lib/generators/templates/helpers/raider_element.tt +4 -4
  28. data/lib/generators/templates/helpers/session_state.tt +1 -1
  29. data/lib/generators/templates/helpers/spec_helper.tt +20 -1
  30. data/lib/generators/templates/helpers/trace_helper.tt +1097 -0
  31. data/lib/generators/templates/helpers/wait_helper.tt +145 -4
  32. data/lib/ruby_raider.rb +9 -2
  33. data/lib/utilities/utilities.rb +7 -0
  34. data/lib/version +1 -1
  35. data/ruby_raider.gemspec +1 -0
  36. data/sig/commands/utility_commands.rbs +2 -0
  37. data/sig/generators/generator.rbs +6 -0
  38. data/sig/generators/helper_generator.rbs +1 -0
  39. data/sig/ruby_raider.rbs +1 -0
  40. data/sig/utilities/utilities.rbs +1 -0
  41. data/spec/commands/raider_commands_spec.rb +25 -1
  42. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +1 -1
  43. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +2 -4
  44. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +2 -4
  45. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +11 -2
  46. data/spec/generators/trace_helper_spec.rb +945 -0
  47. data/spec/integration/content/config_content_spec.rb +20 -0
  48. data/spec/integration/content/content_helper.rb +1 -5
  49. data/spec/integration/content/cucumber_reporter_content_spec.rb +51 -0
  50. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  51. data/spec/integration/content/helper_content_spec.rb +60 -1
  52. data/spec/integration/content/readme_content_spec.rb +46 -0
  53. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  54. data/spec/integration/content/rubocop_content_spec.rb +87 -0
  55. data/spec/integration/content/syntax_validation_spec.rb +31 -51
  56. data/spec/integration/content/test_content_spec.rb +5 -2
  57. data/spec/integration/end_to_end_spec.rb +415 -29
  58. data/spec/integration/frozen_string_literal_matcher.rb +13 -0
  59. data/spec/integration/generators/helpers_generator_spec.rb +26 -0
  60. data/spec/integration/generators/invoke_generators_spec.rb +60 -0
  61. data/spec/integration/helpers/wait_helper_spec.rb +183 -0
  62. data/spec/integration/scaffolding_e2e_spec.rb +1 -5
  63. data/spec/integration/trace_format_checker.rb +141 -0
  64. data/spec/integration/trace_page_server.rb +80 -0
  65. data/spec/utilities/trace_command_spec.rb +66 -0
  66. metadata +29 -2
@@ -6,6 +6,8 @@ require 'open3'
6
6
  require 'json'
7
7
  require 'erb'
8
8
  require 'tmpdir'
9
+ require_relative 'trace_format_checker'
10
+ require_relative 'trace_page_server'
9
11
 
10
12
  # End-to-end integration tests that verify generated projects actually work
11
13
  # These tests:
@@ -265,11 +267,30 @@ describe 'End-to-End Project Generation and Execution' do
265
267
  FileUtils.rm_f(File.join(project, 'features/step_definitions/reporter_failure_steps.rb'))
266
268
  end
267
269
 
270
+ # Runs the project's own RuboCop with the gems its bundle resolved (newer than this gem's dev bundle).
271
+ # It runs from a copy outside this repo, as a user's project would: inside it RuboCop would also read
272
+ # this repo's .rubocop.yml, gemspec and .ruby-version.
273
+ def expect_project_rubocop_clean(project)
274
+ skip 'Bundle install failed' unless bundle_install(project)
275
+
276
+ Dir.mktmpdir('raider_rubocop') do |tmp|
277
+ copy = File.join(tmp, project)
278
+ FileUtils.cp_r(project, copy)
279
+ puts run_in_project(copy, 'bundle exec rubocop -V', timeout: 60)[:stdout]
280
+ result = run_in_project(copy, 'bundle exec rubocop --cache false', timeout: 180)
281
+ expect(result[:exit_code]).to eq(0), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
282
+ end
283
+ end
284
+
268
285
  # Test Web Frameworks (these can run without external services)
269
286
  context 'Web Automation Frameworks' do
270
287
  describe 'Selenium + RSpec' do
271
288
  include_examples 'executable rspec project', 'rspec_selenium'
272
289
 
290
+ it 'passes its own RuboCop with its bundled gems', :slow do
291
+ expect_project_rubocop_clean('rspec_selenium')
292
+ end
293
+
273
294
  it 'saves the login once and reuses it', :aggregate_failures, :slow do
274
295
  skip 'Bundle install failed' unless bundle_install('rspec_selenium')
275
296
 
@@ -300,6 +321,10 @@ describe 'End-to-End Project Generation and Execution' do
300
321
 
301
322
  describe 'Selenium + Cucumber' do
302
323
  include_examples 'executable cucumber project', 'cucumber_selenium'
324
+
325
+ it 'passes its own RuboCop with its bundled gems', :slow do
326
+ expect_project_rubocop_clean('cucumber_selenium')
327
+ end
303
328
  end
304
329
 
305
330
  describe 'Watir + Cucumber' do
@@ -432,6 +457,36 @@ describe 'End-to-End Project Generation and Execution' do
432
457
  end
433
458
  end
434
459
 
460
+ # Browser e2e helpers shared by the failure video and the trace replay contexts
461
+ def browser_installed?(browser)
462
+ if browser == 'firefox'
463
+ ENV['FIREFOX_BINARY'] ? File.executable?(ENV['FIREFOX_BINARY']) : app_or_command?('Firefox', 'firefox')
464
+ else
465
+ app_or_command?('Google Chrome', 'google-chrome') || app_or_command?('Chromium', 'chromium')
466
+ end
467
+ end
468
+
469
+ def app_or_command?(app, command)
470
+ File.directory?("/Applications/#{app}.app") || system("command -v #{command} > /dev/null 2>&1")
471
+ end
472
+
473
+ # A separate project, so the default projects keep resolving raider_reporter 0.10+ from RubyGems
474
+ def generate_video_project(framework, automation)
475
+ name = "video_#{framework}_#{automation}"
476
+ unless LazyProjectGenerator::GENERATED.include?(name)
477
+ generate_framework(framework:, automation:, name:)
478
+ LazyProjectGenerator::GENERATED.add(name)
479
+ use_local_ruby_raider(name)
480
+ expect(run_in_project(name, 'bundle install --quiet', timeout: 300)[:success]).to be(true)
481
+ end
482
+ FileUtils.rm_rf(File.join(name, 'raider-results'))
483
+ name
484
+ end
485
+
486
+ def results(project)
487
+ JSON.parse(File.read(File.join(project, 'raider-results', 'run.json')))['results']
488
+ end
489
+
435
490
  # Failure video replay against real browsers. Needs an ffmpeg with libvpx (RAIDER_FFMPEG_PATH), because the
436
491
  # RaiderHQ ffmpeg release has no checksums yet. Skipped when it, or the browser, is missing. Firefox can be
437
492
  # pointed at a specific binary with FIREFOX_BINARY.
@@ -447,31 +502,6 @@ describe 'End-to-End Project Generation and Execution' do
447
502
  skip "#{browser} is not installed" unless browser_installed?(browser)
448
503
  end
449
504
 
450
- def browser_installed?(browser)
451
- if browser == 'firefox'
452
- ENV['FIREFOX_BINARY'] ? File.executable?(ENV['FIREFOX_BINARY']) : app_or_command?('Firefox', 'firefox')
453
- else
454
- app_or_command?('Google Chrome', 'google-chrome') || app_or_command?('Chromium', 'chromium')
455
- end
456
- end
457
-
458
- def app_or_command?(app, command)
459
- File.directory?("/Applications/#{app}.app") || system("command -v #{command} > /dev/null 2>&1")
460
- end
461
-
462
- # A separate project, so the default projects keep resolving raider_reporter 0.10+ from RubyGems
463
- def generate_video_project(framework, automation)
464
- name = "video_#{framework}_#{automation}"
465
- unless LazyProjectGenerator::GENERATED.include?(name)
466
- generate_framework(framework:, automation:, name:)
467
- LazyProjectGenerator::GENERATED.add(name)
468
- use_local_ruby_raider(name)
469
- expect(run_in_project(name, 'bundle install --quiet', timeout: 300)[:success]).to be(true)
470
- end
471
- FileUtils.rm_rf(File.join(name, 'raider-results'))
472
- name
473
- end
474
-
475
505
  def video_env(overrides = {})
476
506
  { 'HEADLESS' => 'true', 'RAIDER_FFMPEG_PATH' => ffmpeg_path, 'RAIDER_CACHE_DIR' => cache_dir,
477
507
  'RAIDER_TOKEN' => nil, 'RAIDER_ENDPOINT' => nil, 'VIDEO' => nil }.merge(overrides)
@@ -539,10 +569,6 @@ describe 'End-to-End Project Generation and Execution' do
539
569
  RUBY
540
570
  end
541
571
 
542
- def results(project)
543
- JSON.parse(File.read(File.join(project, 'raider-results', 'run.json')))['results']
544
- end
545
-
546
572
  def video_files(project)
547
573
  Dir.glob(File.join(project, 'raider-results', '**', '*.{webm,mp4}'))
548
574
  end
@@ -634,6 +660,366 @@ describe 'End-to-End Project Generation and Execution' do
634
660
  end
635
661
  end
636
662
 
663
+ # Trace replay against headless Chrome, and one example on headless Firefox. The page is served over HTTP from this process (never file://), so
664
+ # WebDriver BiDi reports real network and console entries. raider_reporter ~> 0.12 resolves from RubyGems, or
665
+ # from a checkout at RAIDER_REPORTER_PATH. The example with video on also needs RAIDER_FFMPEG_PATH.
666
+ # Set TRACE_FIXTURE_DIR to keep the failing RSpec example's trace.zip (and an unzipped copy) there.
667
+ context 'Trace replay', :slow do
668
+ let!(:server) { TracePageServer.new }
669
+ let(:cache_dir) { Dir.mktmpdir('raider-cache') }
670
+
671
+ after do
672
+ server.stop
673
+ FileUtils.rm_rf(cache_dir)
674
+ end
675
+
676
+ # Typed into the secret fields; neither may reach the zip.
677
+ def secrets
678
+ %w[hunter2 s3cr3t]
679
+ end
680
+
681
+ # Typed into a plain field; it is kept as the step's value.
682
+ def plain_value
683
+ 'plain-trace-name'
684
+ end
685
+
686
+ def skip_unless_trace_prerequisites(project, browser = 'chrome')
687
+ skip "#{browser == 'chrome' ? 'Google Chrome' : 'Firefox'} is not installed" unless browser_installed?(browser)
688
+ version = run_in_project(project, %(bundle exec ruby -e "require 'raider_reporter/version'; print RaiderReporter::VERSION"))
689
+ skip "the bundle does not resolve raider_reporter ~> 0.12 (set RAIDER_REPORTER_PATH): #{version[:stderr].lines.last}" unless
690
+ version[:success] && Gem::Version.new(version[:stdout]) >= Gem::Version.new('0.12')
691
+ end
692
+
693
+ def trace_env(overrides = {})
694
+ { 'HEADLESS' => 'true', 'VIDEO' => 'off', 'TRACE' => nil, 'RAIDER_CACHE_DIR' => cache_dir,
695
+ 'RAIDER_TOKEN' => nil, 'RAIDER_ENDPOINT' => nil }.merge(overrides)
696
+ end
697
+
698
+ def trace_attachments(result)
699
+ result['attachments'].select { |attachment| attachment['name'] == 'trace' }
700
+ end
701
+
702
+ def trace_files(project)
703
+ Dir.glob(File.join(project, 'raider-results', '**', '*trace.zip'))
704
+ end
705
+
706
+ def trace_zip(project, attachment)
707
+ File.join(project, 'raider-results', attachment['path'])
708
+ end
709
+
710
+ # { entry name => bytes } for every entry of the zip.
711
+ def zip_entries(path)
712
+ Zip::File.open(path) { |zip| zip.entries.to_h { |entry| [entry.name, entry.get_input_stream.read] } }
713
+ end
714
+
715
+ def result_named(project, name)
716
+ results(project).find { |result| result['full_name'].include?(name) }
717
+ end
718
+
719
+ def expect_run(result, exit_code: 1)
720
+ expect(result[:exit_code]).to eq(exit_code), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
721
+ expect(result[:stderr]).not_to include('[trace]')
722
+ end
723
+
724
+ def expect_valid_trace(path)
725
+ expect(TraceFormatChecker.problems(path)).to be_empty
726
+ end
727
+
728
+ def keep_fixture(path)
729
+ dir = ENV.fetch('TRACE_FIXTURE_DIR', nil)
730
+ return unless dir
731
+
732
+ FileUtils.rm_rf(File.join(dir, 'unzipped'))
733
+ FileUtils.mkdir_p(File.join(dir, 'unzipped'))
734
+ FileUtils.cp(path, File.join(dir, 'trace.zip'))
735
+ zip_entries(path).each do |name, bytes|
736
+ FileUtils.mkdir_p(File.dirname(File.join(dir, 'unzipped', name)))
737
+ File.binwrite(File.join(dir, 'unzipped', name), bytes)
738
+ end
739
+ end
740
+
741
+ # The RSpec examples' names, spec file and readers, shared by the Chrome and Firefox examples.
742
+ def failing
743
+ 'fails after driving the page'
744
+ end
745
+
746
+ def passing
747
+ 'passes after driving the page'
748
+ end
749
+
750
+ # Drives the page through the generated WaitHelper, so the trace has action steps.
751
+ # Each example prints the driver's webSocketUrl, which is set only while tracing is on.
752
+ def add_rspec_trace_spec(project)
753
+ File.write(File.join(project, 'spec/trace_replay_spec.rb'), <<~SPEC)
754
+ # frozen_string_literal: true
755
+
756
+ require_relative '../helpers/spec_helper'
757
+
758
+ describe 'Trace replay' do
759
+ it '#{failing}' do
760
+ driver.navigate.to('#{server.url}')
761
+ puts "TRACE_E2E_WEBSOCKET=\#{driver.capabilities[:web_socket_url].inspect}"
762
+ send_keys({ id: 'pw' }, '#{secrets[0]}')
763
+ send_keys({ id: 'pw2' }, '#{secrets[1]}')
764
+ find_element(id: 'name').send_keys('#{plain_value}')
765
+ click({ id: 'go' })
766
+ eventually(timeout: 5) { expect(text(id: 'status')).to eq('answered by /api') }
767
+ eventually(timeout: 1) { expect(text(id: 'title')).to eq('deliberate trace failure') }
768
+ end
769
+
770
+ it '#{passing}' do
771
+ driver.navigate.to('#{server.url}')
772
+ puts "TRACE_E2E_WEBSOCKET=\#{driver.capabilities[:web_socket_url].inspect}"
773
+ send_keys({ id: 'name' }, 'ok')
774
+ eventually { expect(text(id: 'title')).to eq('Trace') }
775
+ end
776
+ end
777
+ SPEC
778
+ end
779
+
780
+ def failing_trace(result)
781
+ expect(trace_attachments(result).size).to eq(1)
782
+ trace_zip(project, trace_attachments(result).first)
783
+ end
784
+
785
+ def steps_of(path)
786
+ TraceFormatChecker.read(path).first['steps']
787
+ end
788
+
789
+ # BiDi screenshots, so window_handle worked as the browsing context id: .jpg entries that are JPEGs.
790
+ def expect_jpeg_screenshots(path, names)
791
+ screenshots = names.grep(%r{\Asteps/\d{4}\.(jpg|png)\z})
792
+ expect(screenshots).not_to be_empty
793
+ expect(screenshots).to all(end_with('.jpg'))
794
+ entries = zip_entries(path)
795
+ screenshots.each { |name| expect(entries[name].byteslice(0, 2).bytes).to eq([0xFF, 0xD8]), name }
796
+ end
797
+
798
+ describe 'Selenium + RSpec on headless Chrome' do
799
+ let(:project) { 'video_rspec_selenium' }
800
+ let(:command) { 'bundle exec rspec spec/trace_replay_spec.rb' }
801
+
802
+ before do
803
+ generate_video_project('rspec', 'selenium')
804
+ skip_unless_trace_prerequisites(project)
805
+ add_rspec_trace_spec(project)
806
+ end
807
+
808
+ after { FileUtils.rm_f(File.join(project, 'spec/trace_replay_spec.rb')) }
809
+
810
+ # A navigate step (Selenium sends get through BiDi with web_socket_url set), and action steps whose
811
+ # elements have a label and a rect.
812
+ def expect_steps(steps)
813
+ expect(steps.map { |step| step['kind'] }).to include('navigate')
814
+ actions = steps.select { |step| %w[click send_keys].include?(step['action']) }
815
+ expect(actions.size).to eq(4)
816
+ expect(actions).to all(include('element' => include('label' => a_kind_of(String)), 'rect' => a_kind_of(Hash)))
817
+ end
818
+
819
+ def expect_network_and_console(trace)
820
+ api = trace['network'].find { |entry| entry['url'].to_s.end_with?('/api') }
821
+ expect(api).to include('method' => 'GET', 'status' => 200, 'mime_type' => 'application/json')
822
+ expect(api['bytes']).to be_positive
823
+ expect(api['duration_ms']).to be >= (TracePageServer::API_DELAY * 1000) - 50
824
+ expect(trace['console']).to include(include('level' => 'error', 'text' => a_string_including('trace e2e error')))
825
+ end
826
+
827
+ # The same-origin stylesheet inlined as <style>, and the page's <script> gone.
828
+ def expect_sanitised_snapshots(path, names)
829
+ entries = zip_entries(path)
830
+ snapshots = names.grep(%r{\Asteps/\d{4}\.html\z}).map { |name| entries[name] }
831
+ expect(snapshots).not_to be_empty
832
+ expect(snapshots).to all(match(/<style[^>]*>[^<]*#{Regexp.escape(TracePageServer::CSS_RULE)}/))
833
+ expect(snapshots).to all(satisfy { |html| !html.match?(/<script/i) })
834
+ end
835
+
836
+ it 'attaches one trace.zip to the failing example only, valid against raider-trace v1, with network and console',
837
+ :aggregate_failures do
838
+ run = run_in_project(project, command, env: trace_env)
839
+ expect_run(run)
840
+ failed = result_named(project, failing)
841
+ attachment = trace_attachments(failed).first
842
+ path = failing_trace(failed)
843
+ keep_fixture(path)
844
+
845
+ expect(run[:stdout]).to include('TRACE_E2E_WEBSOCKET="ws://')
846
+ expect(attachment).to include('content_type' => 'application/vnd.raider.trace+zip', 'attempt' => 1)
847
+ expect(trace_attachments(result_named(project, passing))).to be_empty
848
+ expect(trace_files(project).size).to eq(1)
849
+ expect_valid_trace(path)
850
+
851
+ trace, names = TraceFormatChecker.read(path)
852
+ expect(trace['capabilities']).to include('network' => true, 'console' => true)
853
+ expect_steps(trace['steps'])
854
+ expect_jpeg_screenshots(path, names)
855
+ expect_network_and_console(trace)
856
+ expect_sanitised_snapshots(path, names)
857
+ end
858
+
859
+ it 'masks the typed password', :aggregate_failures do
860
+ run = run_in_project(project, command, env: trace_env)
861
+ expect_run(run)
862
+ path = failing_trace(result_named(project, failing))
863
+
864
+ zip_entries(path).each do |name, bytes|
865
+ secrets.each { |secret| expect(bytes.b).not_to include(secret.b), "#{secret} is in #{name}" }
866
+ end
867
+ typed = steps_of(path).select { |step| step['action'] == 'send_keys' }
868
+ expect(typed.size).to eq(3)
869
+ expect(typed.first(2)).to all(include('value' => nil, 'secret' => true))
870
+ expect(typed.last).to include('value' => plain_value, 'secret' => false)
871
+ end
872
+
873
+ it 'writes no trace and no web_socket_url with TRACE=off', :aggregate_failures do
874
+ run = run_in_project(project, command, env: trace_env('TRACE' => 'off'))
875
+ expect_run(run)
876
+ expect(run[:stdout].scan(/^TRACE_E2E_WEBSOCKET=(.*)$/).flatten).to eq(%w[nil nil])
877
+ expect(results(project).flat_map { |result| trace_attachments(result) }).to be_empty
878
+ expect(trace_files(project)).to be_empty
879
+ end
880
+
881
+ it 'keeps traces for passing examples too with TRACE=on', :aggregate_failures do
882
+ run = run_in_project(project, command, env: trace_env('TRACE' => 'on'))
883
+ expect_run(run)
884
+ [failing, passing].each do |name|
885
+ attachments = trace_attachments(result_named(project, name))
886
+ expect(attachments.size).to eq(1), "#{name} has #{attachments.size} traces"
887
+ expect_valid_trace(trace_zip(project, attachments.first)) if attachments.one?
888
+ end
889
+ expect(trace_files(project).size).to eq(2)
890
+ end
891
+
892
+ # Spec §2 Cost: tracing on failing tests (the default) costs under 15% of the wall-clock time.
893
+ # One warm-up run, then the modes interleaved, three runs each, compared by their medians.
894
+ # Opt-in: CI runs the integration specs on every PR, and runner jitter is the size of the margin.
895
+ it 'keeps the suite within 15% of its TRACE=off wall-clock time' do
896
+ skip 'set TRACE_OVERHEAD=1 to measure trace overhead' unless ENV['TRACE_OVERHEAD']
897
+
898
+ run_in_project(project, command, env: trace_env)
899
+ times = Hash.new { |hash, key| hash[key] = [] }
900
+ 3.times do
901
+ { 'off' => 'off', 'default' => nil }.each do |mode, value|
902
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
903
+ expect_run(run_in_project(project, command, env: trace_env('TRACE' => value)))
904
+ times[mode] << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
905
+ end
906
+ end
907
+ off, traced = times.values_at('off', 'default').map { |runs| runs.sort[1] }
908
+ overhead = (traced - off) / off
909
+ puts format("\n[trace overhead] TRACE=off %<off>s, default %<default>s, medians %<o>.2fs vs %<t>.2fs: %<p>+.1f%%",
910
+ off: times['off'].map { |time| time.round(2) }, default: times['default'].map { |time| time.round(2) },
911
+ o: off, t: traced, p: overhead * 100)
912
+ expect(overhead).to be < 0.15
913
+ end
914
+
915
+ it 'attaches video.webm and trace.zip to the failing example with video on', :aggregate_failures do
916
+ ffmpeg = ENV.fetch('RAIDER_FFMPEG_PATH', '')
917
+ skip 'RAIDER_FFMPEG_PATH is not set to an executable ffmpeg with libvpx' unless
918
+ File.file?(ffmpeg) && File.executable?(ffmpeg)
919
+
920
+ run = run_in_project(project, command, env: trace_env('VIDEO' => nil, 'RAIDER_FFMPEG_PATH' => ffmpeg))
921
+ expect_run(run)
922
+ expect(run[:stderr]).not_to include('[video]')
923
+ attachments = result_named(project, failing)['attachments']
924
+ expect(attachments.map { |attachment| attachment['content_type'] })
925
+ .to include('video/webm', 'application/vnd.raider.trace+zip')
926
+ expect(attachments.map { |attachment| File.basename(attachment['path']) })
927
+ .to include(a_string_ending_with('video.webm'), a_string_ending_with('trace.zip'))
928
+ expect_valid_trace(failing_trace(result_named(project, failing)))
929
+ end
930
+ end
931
+
932
+ # The same RSpec project with config.yml set to firefox; Selenium Manager fetches geckodriver.
933
+ describe 'Selenium + RSpec on headless Firefox' do
934
+ let(:project) { 'video_rspec_selenium' }
935
+ let(:config) { File.join(project, 'config/config.yml') }
936
+ # Read before the switch to firefox and written back after, so the Chrome examples keep chrome.
937
+ let(:original_config) { File.read(config) }
938
+
939
+ before do
940
+ generate_video_project('rspec', 'selenium')
941
+ skip_unless_trace_prerequisites(project, 'firefox')
942
+ File.write(config, original_config.sub(/^browser: .*$/, 'browser: firefox'))
943
+ add_rspec_trace_spec(project)
944
+ end
945
+
946
+ after do
947
+ File.write(config, original_config) if File.exist?(config)
948
+ FileUtils.rm_f(File.join(project, 'spec/trace_replay_spec.rb'))
949
+ end
950
+
951
+ it 'attaches a trace.zip valid against raider-trace v1, with network, console and BiDi JPEG screenshots',
952
+ :aggregate_failures do
953
+ run = run_in_project(project, 'bundle exec rspec spec/trace_replay_spec.rb', env: trace_env, timeout: 240)
954
+ expect_run(run)
955
+ path = failing_trace(result_named(project, failing))
956
+ expect_valid_trace(path)
957
+
958
+ trace, names = TraceFormatChecker.read(path)
959
+ expect(trace['browser']).to include('name' => 'firefox')
960
+ expect(trace['capabilities']).to include('network' => true, 'console' => true)
961
+ expect_jpeg_screenshots(path, names)
962
+ api = trace['network'].find { |entry| entry['url'].to_s.end_with?('/api') }
963
+ expect(api).to include('status' => 200)
964
+ expect(api && api['duration_ms']).to be_a(Integer).and be_positive
965
+ end
966
+ end
967
+
968
+ describe 'Selenium + Cucumber on headless Chrome' do
969
+ let(:project) { 'video_cucumber_selenium' }
970
+
971
+ before do
972
+ generate_video_project('cucumber', 'selenium')
973
+ skip_unless_trace_prerequisites(project)
974
+ add_cucumber_trace_feature(project)
975
+ end
976
+
977
+ after do
978
+ FileUtils.rm_f(File.join(project, 'features/trace_replay.feature'))
979
+ FileUtils.rm_f(File.join(project, 'features/step_definitions/trace_replay_steps.rb'))
980
+ end
981
+
982
+ def add_cucumber_trace_feature(project)
983
+ File.write(File.join(project, 'features/trace_replay.feature'), <<~FEATURE)
984
+ Feature: Trace replay
985
+
986
+ Scenario: A failing traced scenario
987
+ Given the trace page is open
988
+ When I type a name on the trace page
989
+ Then the trace page title is "deliberate trace failure"
990
+ FEATURE
991
+ File.write(File.join(project, 'features/step_definitions/trace_replay_steps.rb'), <<~STEPS)
992
+ # frozen_string_literal: true
993
+
994
+ Given('the trace page is open') do
995
+ driver.navigate.to('#{server.url}')
996
+ end
997
+
998
+ When('I type a name on the trace page') do
999
+ send_keys({ id: 'name' }, '#{plain_value}')
1000
+ click({ id: 'go' })
1001
+ end
1002
+
1003
+ Then('the trace page title is {string}') do |title|
1004
+ eventually(timeout: 1) { expect(text(id: 'title')).to eq(title) }
1005
+ end
1006
+ STEPS
1007
+ end
1008
+
1009
+ it 'attaches a trace.zip with Gherkin step groups to the failing scenario', :aggregate_failures do
1010
+ run = run_in_project(project, 'bundle exec cucumber features/trace_replay.feature', env: trace_env, timeout: 240)
1011
+ expect_run(run)
1012
+ attachments = trace_attachments(result_named(project, 'A failing traced scenario'))
1013
+ expect(attachments.size).to eq(1)
1014
+ expect(attachments.first).to include('content_type' => 'application/vnd.raider.trace+zip', 'attempt' => 1)
1015
+ path = trace_zip(project, attachments.first)
1016
+ expect_valid_trace(path)
1017
+ groups = TraceFormatChecker.read(path).first['steps'].select { |step| step['action'] == 'cucumber_step' }
1018
+ expect(groups).to include(include('kind' => 'group', 'name' => a_string_including('the trace page is open')))
1019
+ end
1020
+ end
1021
+ end
1022
+
637
1023
  # Mobile and Visual frameworks require external services, so we only verify structure
638
1024
  context 'Mobile Automation Frameworks (structure validation only)' do
639
1025
  describe 'iOS + RSpec' do
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+
5
+ # Ruby only reads the magic comment in the comment block before the first line of code. Ruby would
6
+ # accept it indented, but the generated project's RuboCop (Layout/CommentIndentation) would not.
7
+ RSpec::Matchers.define :have_frozen_string_literal do
8
+ match do |content|
9
+ leading_comments = content.lines.take_while { |line| line.strip.empty? || line.start_with?('#') }
10
+ leading_comments.any? { |line| line.chomp == '# frozen_string_literal: true' }
11
+ end
12
+ failure_message { 'expected an unindented frozen_string_literal magic comment before the first line of code' }
13
+ end
@@ -46,6 +46,18 @@ describe HelpersGenerator do
46
46
  end
47
47
  end
48
48
 
49
+ shared_examples 'creates the trace helper' do |name|
50
+ it 'creates the trace helper' do
51
+ expect(File).to exist("#{name}/helpers/trace_helper.rb")
52
+ end
53
+ end
54
+
55
+ shared_examples 'skips the trace helper' do |name|
56
+ it 'does not create the trace helper' do
57
+ expect(File).not_to exist("#{name}/helpers/trace_helper.rb")
58
+ end
59
+ end
60
+
49
61
  shared_examples 'creates rspec helpers' do |name|
50
62
  it 'creates a spec helper file' do
51
63
  expect(File).to exist("#{name}/helpers/spec_helper.rb")
@@ -66,6 +78,7 @@ describe HelpersGenerator do
66
78
 
67
79
  context 'with rspec and selenium' do
68
80
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
81
+ include_examples 'creates the trace helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
69
82
  include_examples 'creates a session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
70
83
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
71
84
  include_examples 'creates the wait helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
@@ -74,6 +87,7 @@ describe HelpersGenerator do
74
87
 
75
88
  context 'with rspec and watir' do
76
89
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
90
+ include_examples 'skips the trace helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
77
91
  include_examples 'creates a session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
78
92
  include_examples 'creates watir helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
79
93
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
@@ -81,6 +95,7 @@ describe HelpersGenerator do
81
95
 
82
96
  context 'with cucumber and selenium' do
83
97
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
98
+ include_examples 'creates the trace helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
84
99
  include_examples 'creates a session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
85
100
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
86
101
  include_examples 'creates the wait helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
@@ -89,6 +104,7 @@ describe HelpersGenerator do
89
104
 
90
105
  context 'with cucumber and watir' do
91
106
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
107
+ include_examples 'skips the trace helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
92
108
  include_examples 'creates a session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
93
109
  include_examples 'creates watir helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
94
110
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
@@ -96,6 +112,7 @@ describe HelpersGenerator do
96
112
 
97
113
  context 'with rspec and appium android' do
98
114
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
115
+ include_examples 'skips the trace helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
99
116
  include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
100
117
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
101
118
  include_examples 'creates the wait helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
@@ -104,6 +121,7 @@ describe HelpersGenerator do
104
121
 
105
122
  context 'with rspec and appium ios' do
106
123
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
124
+ include_examples 'skips the trace helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
107
125
  include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
108
126
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
109
127
  include_examples 'creates the wait helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
@@ -112,6 +130,7 @@ describe HelpersGenerator do
112
130
 
113
131
  context 'with cucumber and appium android' do
114
132
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
133
+ include_examples 'skips the trace helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
115
134
  include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
116
135
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
117
136
  include_examples 'creates the wait helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
@@ -120,6 +139,7 @@ describe HelpersGenerator do
120
139
 
121
140
  context 'with cucumber and appium ios' do
122
141
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
142
+ include_examples 'skips the trace helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
123
143
  include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
124
144
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
125
145
  include_examples 'creates the wait helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
@@ -128,6 +148,7 @@ describe HelpersGenerator do
128
148
 
129
149
  context 'with rspec and appium cross platform' do
130
150
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
151
+ include_examples 'skips the trace helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
131
152
  include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
132
153
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
133
154
  include_examples 'creates the wait helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
@@ -137,6 +158,7 @@ describe HelpersGenerator do
137
158
 
138
159
  context 'with cucumber and appium cross platform' do
139
160
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
161
+ include_examples 'skips the trace helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
140
162
  include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
141
163
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
142
164
  include_examples 'creates the wait helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
@@ -153,6 +175,10 @@ describe HelpersGenerator do
153
175
  expect(File).to exist("#{name}/helpers/wait_helper.rb")
154
176
  expect(File).to exist("#{name}/helpers/raider_element.rb")
155
177
  end
178
+
179
+ it "#{automation == 'selenium' ? 'creates' : 'skips'} the trace helper" do
180
+ expect(File.exist?("#{name}/helpers/trace_helper.rb")).to eq(automation == 'selenium')
181
+ end
156
182
  end
157
183
  end
158
184