ruby_raider 3.12.1 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/lib/commands/utility_commands.rb +16 -0
  4. data/lib/generators/cucumber/templates/partials/selenium_env.tt +65 -1
  5. data/lib/generators/generator.rb +10 -1
  6. data/lib/generators/helper_generator.rb +7 -0
  7. data/lib/generators/templates/common/gemfile.tt +1 -1
  8. data/lib/generators/templates/common/partials/web_config.tt +7 -0
  9. data/lib/generators/templates/common/read_me.tt +45 -0
  10. data/lib/generators/templates/helpers/driver_helper.tt +22 -0
  11. data/lib/generators/templates/helpers/partials/selenium_driver.tt +11 -1
  12. data/lib/generators/templates/helpers/raider_element.tt +4 -4
  13. data/lib/generators/templates/helpers/spec_helper.tt +19 -0
  14. data/lib/generators/templates/helpers/trace_helper.tt +1097 -0
  15. data/lib/generators/templates/helpers/wait_helper.tt +145 -4
  16. data/lib/utilities/utilities.rb +7 -0
  17. data/lib/version +1 -1
  18. data/ruby_raider.gemspec +1 -0
  19. data/sig/commands/utility_commands.rbs +2 -0
  20. data/sig/generators/generator.rbs +4 -0
  21. data/sig/generators/helper_generator.rbs +1 -0
  22. data/sig/utilities/utilities.rbs +1 -0
  23. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +11 -2
  24. data/spec/generators/trace_helper_spec.rb +945 -0
  25. data/spec/integration/content/config_content_spec.rb +20 -0
  26. data/spec/integration/content/cucumber_reporter_content_spec.rb +51 -0
  27. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  28. data/spec/integration/content/helper_content_spec.rb +60 -1
  29. data/spec/integration/content/readme_content_spec.rb +46 -0
  30. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  31. data/spec/integration/end_to_end_spec.rb +392 -29
  32. data/spec/integration/generators/helpers_generator_spec.rb +26 -0
  33. data/spec/integration/helpers/wait_helper_spec.rb +183 -0
  34. data/spec/integration/trace_format_checker.rb +141 -0
  35. data/spec/integration/trace_page_server.rb +80 -0
  36. data/spec/utilities/trace_command_spec.rb +66 -0
  37. metadata +27 -2
@@ -472,6 +472,189 @@ describe 'WaitHelper' do
472
472
  end
473
473
  end
474
474
 
475
+ describe '.observer' do
476
+ # Records every hook call; tokens are the call's index.
477
+ let(:observer) do
478
+ Class.new do
479
+ attr_reader :calls
480
+
481
+ def initialize = @calls = []
482
+
483
+ %i[action_started group_started].each do |hook|
484
+ define_method(hook) do |**details|
485
+ @calls << [hook, details]
486
+ @calls.size - 1
487
+ end
488
+ end
489
+
490
+ %i[action_ready action_finished group_retried group_finished].each do |hook|
491
+ define_method(hook) do |token, **details|
492
+ @calls << [hook, token, details]
493
+ end
494
+ end
495
+ end.new
496
+ end
497
+
498
+ around do |example|
499
+ WaitHelper.observer = observer
500
+ example.run
501
+ ensure
502
+ WaitHelper.observer = nil
503
+ end
504
+
505
+ def started = observer.calls.select { |call| call.first == :action_started }.map(&:last)
506
+ def finished = observer.calls.select { |call| call.first == :action_finished }.map(&:last)
507
+
508
+ it 'reports a click with its locator, source line, waited time and the ready element' do
509
+ button = element
510
+ driver.script({ id: 'go' }, nil, button)
511
+ line = __LINE__ + 1
512
+ page.click({ id: 'go' }, timeout: 1)
513
+
514
+ expect(observer.calls.map(&:first)).to eq(%i[action_started action_ready action_finished])
515
+ expect(started.first).to include(action: :click, locator: { strategy: 'id', selector: 'go' }, within: [],
516
+ source: { file: Pathname(__FILE__).relative_path_from(Dir.pwd).to_s, line: line })
517
+ .and(satisfy { |details| !details.key?(:label) })
518
+ expect(observer.calls[2][1]).to eq(0)
519
+ expect(finished.first).to include(status: :passed, retries: 2, error: nil, element: button)
520
+ expect(finished.first[:waited_ms]).to be_between(50, 500)
521
+ end
522
+
523
+ it 'reports the keys of send_keys and how and what of select_by as args, and nil args for other actions' do
524
+ driver.script({ id: 'user' }, element)
525
+ driver.script({ id: 'colour' }, WaitHelperSpec::FakeSelect.new)
526
+ page.send_keys({ id: 'user' }, 'admin', :enter, timeout: 1)
527
+ page.select_by({ id: 'colour' }, :text, 'Blue', timeout: 1)
528
+ page.click({ id: 'user' }, timeout: 1)
529
+
530
+ expect(started.map { |event| event.values_at(:action, :args) })
531
+ .to eq([[:send_keys, ['admin', :enter]], [:select_by, [:text, 'Blue']], [:click, nil]])
532
+ end
533
+
534
+ it 'reports action_ready with the ready element before the action block runs' do
535
+ field = element
536
+ driver.script({ id: 'user' }, nil, field)
537
+ log_when_ready = []
538
+ allow(observer).to receive(:action_ready).and_wrap_original do |original, token, **details|
539
+ log_when_ready << details[:element].log.dup
540
+ original.call(token, **details)
541
+ end
542
+ page.send_keys({ id: 'user' }, 'admin', timeout: 1)
543
+
544
+ expect(observer.calls.map(&:first)).to eq(%i[action_started action_ready action_finished])
545
+ expect(observer.calls[1]).to eq([:action_ready, 0, { element: field }])
546
+ expect(log_when_ready).to eq([[]])
547
+ expect(field.log).to eq([[:send_keys, 'admin']])
548
+ end
549
+
550
+ it 'reports a chained find with within, outermost first' do
551
+ driver.script({ css: '.form' }, element.script({ tag_name: 'td' }, element.script({ id: 'email' }, element)))
552
+ page.find_element(css: '.form').find_element(tag_name: 'td').find_element(id: 'email')
553
+
554
+ expect(started.last).to include(action: :find_element, locator: { strategy: 'id', selector: 'email' },
555
+ within: [{ strategy: 'css', selector: '.form' }, { strategy: 'tag_name', selector: 'td' }])
556
+ end
557
+
558
+ it 'reports a timed-out action as failed with its error, then re-raises' do
559
+ driver.script({ id: 'gone' }, nil)
560
+ expect { page.click({ id: 'gone' }, timeout: 0.3) }.to raise_error(WaitHelper::TimeoutError)
561
+
562
+ expect(finished.first).to include(status: :failed, element: nil)
563
+ expect(finished.first[:error]).to be_a(WaitHelper::TimeoutError)
564
+ expect(finished.first[:retries]).to be >= 2
565
+ end
566
+
567
+ it 'reports eventually as a group with the retries it took' do
568
+ tries = 0
569
+ page.eventually(timeout: 2) { expect(tries += 1).to eq(3) }
570
+
571
+ group = observer.calls.find { |call| call.first == :group_started }.last
572
+ expect(group).to include(action: :eventually)
573
+ expect(group[:source]).to include(line: be_a(Integer))
574
+ expect(observer.calls.last).to match([:group_finished, 0, { status: :passed, retries: 2, error: nil }])
575
+ end
576
+
577
+ it 'reports group_retried with the group token and the earlier error before each new pass of eventually' do
578
+ tries = 0
579
+ page.eventually(timeout: 2) { expect(tries += 1).to eq(3) }
580
+
581
+ retried = observer.calls.select { |call| call.first == :group_retried }
582
+ expect(retried.map { |call| call[1] }).to eq([0, 0])
583
+ expect(retried.map { |call| call.last[:error] }).to all(be_a(RSpec::Expectations::ExpectationNotMetError))
584
+ expect(observer.calls.map(&:first)).to eq(%i[group_started group_retried group_retried group_finished])
585
+ end
586
+
587
+ it 'reports a failing eventually as failed with its error' do
588
+ tries = 0
589
+ expect { page.eventually(timeout: 0.2) { expect(tries += 1).to eq(0) } }.to raise_error(RSpec::Expectations::ExpectationNotMetError)
590
+ expect(observer.calls.last.last).to include(status: :failed, error: be_a(RSpec::Expectations::ExpectationNotMetError))
591
+ end
592
+
593
+ it 'normalises class to class_name and link to link_text' do
594
+ aliases = { css_selector: 'css', class: 'class_name', link: 'link_text', partial_link: 'partial_link_text', name: 'name' }
595
+ aliases.each_key do |strategy|
596
+ driver.script({ strategy => 'x' }, element)
597
+ page.displayed?({ strategy => 'x' }, timeout: 0)
598
+ end
599
+
600
+ expect(started.map { |event| event[:locator][:strategy] }).to eq(aliases.values)
601
+ end
602
+
603
+ it 'never takes the source from a file in helpers/, such as the trace helper' do
604
+ # The given path of wait_helper.rb, and the real path of raider_element.rb (via __dir__).
605
+ given, real = WaitHelper::Waiter::HELPER_FILES.map { |file| File.dirname(file) }
606
+
607
+ expect(WaitHelper::Observation.project?(File.join(given, 'trace_helper.rb'))).to be(false)
608
+ expect(WaitHelper::Observation.project?(File.join(real, 'driver_helper.rb'))).to be(false)
609
+ expect(WaitHelper::Observation.project?(File.expand_path('spec/login_spec.rb'))).to be(true)
610
+ end
611
+
612
+ it 'reports no label for an action called from a page_objects method' do
613
+ Dir.mktmpdir do |dir|
614
+ FileUtils.mkdir_p(File.join(dir, 'page_objects'))
615
+ path = File.join(dir, 'page_objects', 'login.rb')
616
+ File.write(path, "def login_button(page) = page.click({ id: 'go' }, timeout: 1)\n")
617
+ load path
618
+ driver.script({ id: 'go' }, element)
619
+ login_button(page)
620
+ end
621
+
622
+ expect(started.first).not_to have_key(:label)
623
+ expect(started.first[:source][:file]).to end_with('page_objects/login.rb')
624
+ end
625
+
626
+ it 'drops the observer, warns once, and still clicks when working out the source raises' do
627
+ button = element
628
+ driver.script({ id: 'go' }, button)
629
+ allow(Dir).to receive(:pwd).and_raise(Errno::ENOENT)
630
+
631
+ expect { 2.times { page.click({ id: 'go' }, timeout: 1) } }.to output(/\A\[trace\] observer stopped: Errno::ENOENT.*\n\z/).to_stderr
632
+ expect(button.log).to eq([%i[click]] * 2)
633
+ expect(WaitHelper.observer).to be_nil
634
+ end
635
+
636
+ it 'drops an observer that raises, warns once, and still clicks' do
637
+ allow(observer).to receive(:action_started).and_raise('boom')
638
+ button = element
639
+ driver.script({ id: 'go' }, button)
640
+
641
+ expect { 2.times { page.click({ id: 'go' }, timeout: 1) } }.to output(/\A\[trace\] observer stopped: RuntimeError: boom\n\z/).to_stderr
642
+ expect(button.log).to eq([%i[click]] * 2)
643
+ expect(WaitHelper.observer).to be_nil
644
+ end
645
+
646
+ it 'still prints observer stopped under -W0, where Kernel#warn is silent' do
647
+ allow(observer).to receive(:action_started).and_raise('boom')
648
+ driver.script({ id: 'go' }, element)
649
+ verbose = $VERBOSE
650
+ $VERBOSE = nil
651
+
652
+ expect { page.click({ id: 'go' }, timeout: 1) }.to output("[trace] observer stopped: RuntimeError: boom\n").to_stderr
653
+ ensure
654
+ $VERBOSE = verbose
655
+ end
656
+ end
657
+
475
658
  describe '.default_timeout' do
476
659
  around do |example|
477
660
  Dir.mktmpdir do |dir|
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'zip'
5
+
6
+ # Checks a trace.zip against the raider-trace v1 format (trace viewer spec §1) and
7
+ # returns the problems it finds, so a spec can `expect(problems).to be_empty`.
8
+ # Stricter than readers need to be: an unknown locator strategy is a problem here.
9
+ module TraceFormatChecker
10
+ ENTRY_NAME = %r{\Atrace\.json\z|\Asteps/\d{4}\.(jpg|png|html|xml)\z}
11
+ TIMESTAMP = /\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\z/
12
+ KINDS = %w[navigate action group failure].freeze
13
+ STRATEGIES = %w[id css xpath name link_text partial_link_text class_name tag_name].freeze
14
+ # The required keys and the types each may have: the top level, then every step.
15
+ FIELDS = {
16
+ top: {
17
+ 'format' => [String], 'version' => [Integer], 'producer' => [Hash], 'test' => [Hash],
18
+ 'browser' => [Hash], 'viewport' => [Hash], 'device_pixel_ratio' => [Integer, Float],
19
+ 'started_at' => [String], 'finished_at' => [String], 'capabilities' => [Hash],
20
+ 'truncated' => [Hash], 'steps' => [Array], 'network' => [Array], 'console' => [Array]
21
+ }.freeze,
22
+ step: {
23
+ 'index' => [Integer], 'parent_index' => [Integer, NilClass], 'kind' => [String], 'action' => [String],
24
+ 'locator' => [Hash, NilClass], 'url' => [String, NilClass], 'page_title' => [String, NilClass],
25
+ 'started_at' => [String], 'duration_ms' => [Integer], 'waited_ms' => [Integer], 'retries' => [Integer],
26
+ 'status' => [String], 'error' => [Hash, NilClass], 'source' => [Hash, NilClass], 'rect' => [Hash, NilClass],
27
+ 'screenshot' => [String, NilClass], 'snapshot' => [String, NilClass]
28
+ }.freeze
29
+ }.freeze
30
+
31
+ # The parsed trace.json and the zip's entry names.
32
+ def self.read(path)
33
+ Zip::File.open(path) do |zip|
34
+ [JSON.parse(zip.read('trace.json')), zip.entries.map(&:name)]
35
+ end
36
+ end
37
+
38
+ def self.problems(path)
39
+ trace, names = read(path)
40
+ problems = names.grep_v(ENTRY_NAME).map { |name| "entry #{name.inspect} is not an allowed name" }
41
+ problems.concat(identity(trace)).concat(typed('trace', trace, FIELDS[:top]))
42
+ problems.concat(error_rules('test', trace['test'])) if trace['test'].is_a?(Hash)
43
+ return problems unless trace['steps'].is_a?(Array)
44
+
45
+ problems.concat(steps(trace['steps'], names)).concat(events(trace))
46
+ rescue Zip::Error, JSON::ParserError, Errno::ENOENT => e
47
+ ["not a readable trace.zip: #{e.class}: #{e.message}"]
48
+ end
49
+
50
+ def self.identity(trace)
51
+ problems = []
52
+ problems << "format is #{trace['format'].inspect}, not \"raider-trace\"" unless trace['format'] == 'raider-trace'
53
+ problems << "version is #{trace['version'].inspect}, not 1" unless trace['version'] == 1
54
+ problems
55
+ end
56
+
57
+ def self.typed(where, hash, fields)
58
+ fields.flat_map do |key, types|
59
+ next ["#{where} has no #{key.inspect}"] unless hash.key?(key)
60
+
61
+ value = hash[key]
62
+ next [] if types.any? { |type| value.is_a?(type) }
63
+
64
+ ["#{where}.#{key} is #{value.inspect}, not #{types.join(' or ')}"]
65
+ end
66
+ end
67
+
68
+ def self.steps(steps, names)
69
+ steps.each_with_index.flat_map do |step, position|
70
+ where = "steps[#{position}]"
71
+ next ["#{where} is not an object"] unless step.is_a?(Hash)
72
+
73
+ typed(where, step, FIELDS[:step]) + step_rules(where, step, position, names)
74
+ end
75
+ end
76
+
77
+ def self.step_rules(where, step, position, names)
78
+ problems = []
79
+ problems << "#{where}.index is #{step['index'].inspect}, not #{position + 1}" unless step['index'] == position + 1
80
+ problems << "#{where}.kind #{step['kind'].inspect} is not one of #{KINDS.join(', ')}" unless KINDS.include?(step['kind'])
81
+ problems << "#{where}.started_at #{step['started_at'].inspect} is not UTC ISO-8601 with ms" unless TIMESTAMP.match?(step['started_at'].to_s)
82
+ parent = step['parent_index']
83
+ problems << "#{where}.parent_index #{parent} is not an earlier step" if parent && !parent.between?(1, position)
84
+ problems.concat(error_rules(where, step))
85
+ problems.concat(locator_rules(where, step['locator']))
86
+ problems.concat(typed_rules(where, step))
87
+ problems.concat(media_rules(where, step, names))
88
+ end
89
+
90
+ # error is null or { "class": String, "message": String }, on steps and on test.
91
+ def self.error_rules(where, holder)
92
+ return ["#{where} has no \"error\""] unless holder.key?('error')
93
+
94
+ error = holder['error']
95
+ return [] if error.nil?
96
+ return ["#{where}.error is #{error.inspect}, not null or an object"] unless error.is_a?(Hash)
97
+
98
+ %w[class message].filter_map { |key| "#{where}.error.#{key} is not a string" unless error[key].is_a?(String) }
99
+ end
100
+
101
+ # label is optional: ruby_raider never writes it, but another producer may.
102
+ def self.locator_rules(where, locator)
103
+ return [] unless locator.is_a?(Hash)
104
+
105
+ label = locator.key?('label') && !locator['label'].is_a?(String) ? ["#{where}.locator.label is not a string"] : []
106
+ label + [locator, *Array(locator['within'])].filter_map do |one|
107
+ strategy = one['strategy']
108
+ "#{where}.locator strategy #{strategy.inspect} is not canonical" unless STRATEGIES.include?(strategy)
109
+ end
110
+ end
111
+
112
+ # value and secret exist only on send_keys and select_by; a secret value is null.
113
+ # element, when present, is { tag, label?, role? } on an action step.
114
+ def self.typed_rules(where, step)
115
+ problems = []
116
+ typed = %w[send_keys select_by].include?(step['action'])
117
+ problems << "#{where} has value/secret but is not send_keys or select_by" if !typed && (step.key?('value') || step.key?('secret'))
118
+ problems << "#{where} is #{step['action']} without value and secret" if typed && !(step.key?('value') && step.key?('secret'))
119
+ problems << "#{where}.secret is true but value is not null" if step['secret'] == true && !step['value'].nil?
120
+ element = step['element']
121
+ problems << "#{where}.element is not an action step's { tag, label?, role? }" if step.key?('element') && !(element.is_a?(Hash) && element['tag'].is_a?(String) && step['kind'] == 'action')
122
+ problems
123
+ end
124
+
125
+ def self.media_rules(where, step, names)
126
+ %w[screenshot snapshot].filter_map do |key|
127
+ entry = step[key]
128
+ "#{where}.#{key} #{entry.inspect} is not an entry in the zip" if entry && !names.include?(entry)
129
+ end
130
+ end
131
+
132
+ def self.events(trace)
133
+ count = [trace['steps'].size, 1].max
134
+ %w[network console].flat_map do |list|
135
+ Array(trace[list]).each_with_index.filter_map do |event, position|
136
+ index = event.is_a?(Hash) ? event['step_index'] : nil
137
+ "#{list}[#{position}].step_index #{index.inspect} is not between 1 and #{count}" unless index.is_a?(Integer) && index.between?(1, count)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ # Serves the trace e2e page over HTTP on 127.0.0.1 (never file://), so the browser makes
6
+ # real requests that WebDriver BiDi reports: the page, its same-origin stylesheet and /api.
7
+ class TracePageServer
8
+ CSS_RULE = '#title { color: rgb(1, 2, 3); }'
9
+ PAGE = <<~HTML
10
+ <!DOCTYPE html>
11
+ <html>
12
+ <head>
13
+ <title>Trace e2e</title>
14
+ <link rel="stylesheet" href="/style.css">
15
+ </head>
16
+ <body>
17
+ <h1 id="title">Trace</h1>
18
+ <input type="password" id="pw" placeholder="Password">
19
+ <input type="text" id="pw2" autocomplete="current-password" placeholder="Confirm password">
20
+ <input id="name" placeholder="Name">
21
+ <button id="go">Go</button>
22
+ <p id="status"></p>
23
+ <script>
24
+ document.getElementById('go').addEventListener('click', function () {
25
+ fetch('/api').then(function (response) { return response.json(); })
26
+ .then(function (body) { document.getElementById('status').textContent = body.status; });
27
+ console.error('trace e2e error');
28
+ });
29
+ </script>
30
+ </body>
31
+ </html>
32
+ HTML
33
+ ROUTES = {
34
+ '/' => ['200 OK', 'text/html; charset=utf-8', PAGE],
35
+ '/style.css' => ['200 OK', 'text/css', "#{CSS_RULE}\n"],
36
+ '/api' => ['200 OK', 'application/json', '{"status":"answered by /api"}']
37
+ }.freeze
38
+ NOT_FOUND = ['404 Not Found', 'text/plain', 'not found'].freeze
39
+ # /api answers this late, so a network entry's duration_ms has something to measure.
40
+ API_DELAY = 0.3
41
+
42
+ attr_reader :url
43
+
44
+ def initialize
45
+ @server = TCPServer.new('127.0.0.1', 0)
46
+ @url = "http://127.0.0.1:#{@server.addr[1]}/"
47
+ @thread = Thread.new { accept_loop }
48
+ end
49
+
50
+ def stop
51
+ @thread.kill
52
+ @server.close
53
+ end
54
+
55
+ private
56
+
57
+ # A thread per connection: Chrome opens speculative connections that may send nothing.
58
+ def accept_loop
59
+ loop do
60
+ client = @server.accept
61
+ Thread.new(client) { |socket| answer(socket) }
62
+ end
63
+ rescue IOError
64
+ nil
65
+ end
66
+
67
+ def answer(socket)
68
+ request_line = socket.gets.to_s
69
+ nil while (line = socket.gets) && line != "\r\n"
70
+ path = request_line.split[1].to_s.split('?').first
71
+ status, type, body = ROUTES.fetch(path, NOT_FOUND)
72
+ sleep(API_DELAY) if path == '/api'
73
+ socket.write("HTTP/1.1 #{status}\r\nContent-Type: #{type}\r\nContent-Length: #{body.bytesize}\r\n" \
74
+ "Cache-Control: no-store\r\nConnection: close\r\n\r\n#{body}")
75
+ rescue StandardError => e
76
+ warn "[trace page server] #{e.class}: #{e.message}"
77
+ ensure
78
+ socket.close
79
+ end
80
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+ require 'yaml'
6
+ require 'stringio'
7
+ require_relative '../../lib/commands/utility_commands'
8
+
9
+ # rubocop:disable RSpec/FilePath
10
+ RSpec.describe UtilityCommands do
11
+ let(:dir) { Dir.mktmpdir('raider-trace-project') }
12
+ let(:config_path) { File.join(dir, 'config', 'config.yml') }
13
+
14
+ around do |example|
15
+ FileUtils.mkdir_p(File.join(dir, 'config'))
16
+ File.write(config_path, "browser: chrome\ntimeout: 10\ntrace:\n enabled: true\n max_steps: 200\n max_mb: 20\n")
17
+ Dir.chdir(dir) { example.run }
18
+ ensure
19
+ FileUtils.rm_rf(dir)
20
+ end
21
+
22
+ def run_cli(*args)
23
+ out = StringIO.new
24
+ err = StringIO.new
25
+ status = 0
26
+ $stdout = out
27
+ $stderr = err
28
+ begin
29
+ described_class.start(args)
30
+ rescue SystemExit => e
31
+ status = e.status
32
+ ensure
33
+ $stdout = STDOUT
34
+ $stderr = STDERR
35
+ end
36
+ [out.string, err.string, status]
37
+ end
38
+
39
+ describe 'trace on/off' do
40
+ it 'round-trips trace.enabled, keeping the other trace keys and config keys' do
41
+ out, _err, status = run_cli('trace', 'off')
42
+ config = YAML.load_file(config_path)
43
+ expect([status, out]).to match([0, /Trace capture disabled/])
44
+ expect(config).to include('browser' => 'chrome', 'timeout' => 10, 'trace' => { 'enabled' => false, 'max_steps' => 200, 'max_mb' => 20 })
45
+
46
+ out, = run_cli('trace', 'on')
47
+ expect(out).to include('Trace capture enabled')
48
+ expect(YAML.load_file(config_path)['trace']).to eq('enabled' => true, 'max_steps' => 200, 'max_mb' => 20)
49
+ end
50
+
51
+ it 'adds a trace block to a config that has none' do
52
+ File.write(config_path, "browser: chrome\n")
53
+ run_cli('trace', 'off')
54
+ expect(YAML.load_file(config_path)['trace']).to eq('enabled' => false)
55
+ end
56
+ end
57
+
58
+ describe 'trace with an unknown subcommand' do
59
+ it 'exits 1 with usage' do
60
+ _out, err, status = run_cli('trace', 'bogus')
61
+ expect(status).to eq(1)
62
+ expect(err).to include("Unknown trace command 'bogus'", 'on|off')
63
+ end
64
+ end
65
+ end
66
+ # rubocop:enable RSpec/FilePath
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_raider
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.1
4
+ version: 3.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Agustin Pequeno
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-01 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler-audit
@@ -122,6 +122,26 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '3.1'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubyzip
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '2.3'
132
+ - - "<"
133
+ - !ruby/object:Gem::Version
134
+ version: '4'
135
+ type: :development
136
+ prerelease: false
137
+ version_requirements: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '2.3'
142
+ - - "<"
143
+ - !ruby/object:Gem::Version
144
+ version: '4'
125
145
  - !ruby/object:Gem::Dependency
126
146
  name: selenium-webdriver
127
147
  requirement: !ruby/object:Gem::Requirement
@@ -292,6 +312,7 @@ files:
292
312
  - lib/generators/templates/helpers/raider_element.tt
293
313
  - lib/generators/templates/helpers/session_state.tt
294
314
  - lib/generators/templates/helpers/spec_helper.tt
315
+ - lib/generators/templates/helpers/trace_helper.tt
295
316
  - lib/generators/templates/helpers/video_helper.tt
296
317
  - lib/generators/templates/helpers/wait_helper.tt
297
318
  - lib/ruby_raider.rb
@@ -353,6 +374,7 @@ files:
353
374
  - spec/generators/fixtures/templates/test_partial.tt
354
375
  - spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb
355
376
  - spec/generators/template_renderer_spec.rb
377
+ - spec/generators/trace_helper_spec.rb
356
378
  - spec/generators/video_helper_spec.rb
357
379
  - spec/integration/commands/browser_update_after_creation_spec.rb
358
380
  - spec/integration/commands/scaffolding_commands_spec.rb
@@ -391,6 +413,8 @@ files:
391
413
  - spec/integration/scaffolding_e2e_spec.rb
392
414
  - spec/integration/settings_helper.rb
393
415
  - spec/integration/spec_helper.rb
416
+ - spec/integration/trace_format_checker.rb
417
+ - spec/integration/trace_page_server.rb
394
418
  - spec/menus/menu_generator_spec.rb
395
419
  - spec/scaffolding/name_normalizer_spec.rb
396
420
  - spec/scaffolding/scaffold_project_detector_spec.rb
@@ -401,6 +425,7 @@ files:
401
425
  - spec/utilities/desktop_downloader_spec.rb
402
426
  - spec/utilities/ffmpeg_spec.rb
403
427
  - spec/utilities/headless_config_spec.rb
428
+ - spec/utilities/trace_command_spec.rb
404
429
  - spec/utilities/utilities_spec.rb
405
430
  - spec/utilities/video_command_spec.rb
406
431
  homepage: https://github.com/RubyRaider/ruby_raider