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.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/commands/utility_commands.rb +16 -0
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +65 -1
- data/lib/generators/generator.rb +10 -1
- data/lib/generators/helper_generator.rb +7 -0
- data/lib/generators/templates/common/gemfile.tt +1 -1
- data/lib/generators/templates/common/partials/web_config.tt +7 -0
- data/lib/generators/templates/common/read_me.tt +45 -0
- data/lib/generators/templates/helpers/driver_helper.tt +22 -0
- data/lib/generators/templates/helpers/partials/selenium_driver.tt +11 -1
- data/lib/generators/templates/helpers/raider_element.tt +4 -4
- data/lib/generators/templates/helpers/spec_helper.tt +19 -0
- data/lib/generators/templates/helpers/trace_helper.tt +1097 -0
- data/lib/generators/templates/helpers/wait_helper.tt +145 -4
- data/lib/utilities/utilities.rb +7 -0
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/commands/utility_commands.rbs +2 -0
- data/sig/generators/generator.rbs +4 -0
- data/sig/generators/helper_generator.rbs +1 -0
- data/sig/utilities/utilities.rbs +1 -0
- data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +11 -2
- data/spec/generators/trace_helper_spec.rb +945 -0
- data/spec/integration/content/config_content_spec.rb +20 -0
- data/spec/integration/content/cucumber_reporter_content_spec.rb +51 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/content/helper_content_spec.rb +60 -1
- data/spec/integration/content/readme_content_spec.rb +46 -0
- data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +392 -29
- data/spec/integration/generators/helpers_generator_spec.rb +26 -0
- data/spec/integration/helpers/wait_helper_spec.rb +183 -0
- data/spec/integration/trace_format_checker.rb +141 -0
- data/spec/integration/trace_page_server.rb +80 -0
- data/spec/utilities/trace_command_spec.rb +66 -0
- 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.
|
|
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-
|
|
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
|