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
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
require 'yaml'
|
|
8
|
+
require 'zip'
|
|
9
|
+
require_relative '../../lib/generators/helper_generator'
|
|
10
|
+
require_relative '../integration/trace_format_checker'
|
|
11
|
+
|
|
12
|
+
$LOAD_PATH.unshift(File.expand_path('fixtures/video_helper_stubs', __dir__))
|
|
13
|
+
require 'selenium-webdriver'
|
|
14
|
+
|
|
15
|
+
# Stands in for Selenium::WebDriver::BiDi (4.52): session.subscribe(events), add_callback(event, &block)
|
|
16
|
+
# and send_cmd(method, **params). emit delivers an event on its own thread, as the websocket does.
|
|
17
|
+
class FakeTraceBidi
|
|
18
|
+
attr_reader :subscribed, :callbacks, :commands
|
|
19
|
+
attr_accessor :failure, :screenshot
|
|
20
|
+
|
|
21
|
+
def initialize(screenshot)
|
|
22
|
+
@screenshot = screenshot
|
|
23
|
+
@subscribed = []
|
|
24
|
+
@callbacks = Hash.new { |hash, event| hash[event] = [] }
|
|
25
|
+
@commands = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def session
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def subscribe(events, _browsing_contexts = nil)
|
|
33
|
+
raise failure if failure
|
|
34
|
+
|
|
35
|
+
@subscribed << Array(events)
|
|
36
|
+
{}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add_callback(event, &block)
|
|
40
|
+
@callbacks[event] << block
|
|
41
|
+
block.object_id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def send_cmd(method, **params)
|
|
45
|
+
@commands << [method, params]
|
|
46
|
+
raise screenshot if screenshot.is_a?(Exception)
|
|
47
|
+
|
|
48
|
+
{ 'data' => [screenshot].pack('m0') }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def emit(event, params)
|
|
52
|
+
@callbacks[event].each { |callback| Thread.new { callback.call(params) }.join }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Renders the real trace_helper.tt for a Selenium project, loads it, and drives its
|
|
57
|
+
# Recorder the way WaitHelper's observer hook and the navigation listener do, with a
|
|
58
|
+
# driver double. The zips it writes are read back with rubyzip.
|
|
59
|
+
# rubocop:disable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/ExpectOutput, RSpec/MultipleExpectations, RSpec/ExampleLength
|
|
60
|
+
describe 'Generated TraceHelper' do
|
|
61
|
+
let(:template) { File.expand_path('../../lib/generators/templates/helpers/trace_helper.tt', __dir__) }
|
|
62
|
+
let(:project_dir) { Dir.mktmpdir('trace-helper-project') }
|
|
63
|
+
let(:trace_config) { { 'enabled' => true, 'max_steps' => 200, 'max_mb' => 20 } }
|
|
64
|
+
let(:env) { {} }
|
|
65
|
+
let(:png) { "\x89PNG\r\n\x1A\nfake-png".b }
|
|
66
|
+
let(:jpeg) { "\xFF\xD8\xFFfake-jpeg".b }
|
|
67
|
+
let(:bidi) { FakeTraceBidi.new(jpeg) }
|
|
68
|
+
let(:html) { "<html><head><title>Login</title></head><body>#{'<p>row</p>' * 50}</body></html>" }
|
|
69
|
+
let(:capabilities) { double('capabilities', browser_name: 'chrome', browser_version: '131.0.6778.85') }
|
|
70
|
+
let(:window) { double('window', size: double('size', width: 1920, height: 1080)) }
|
|
71
|
+
let(:driver) do
|
|
72
|
+
double('driver', screenshot_as: png, page_source: html, current_url: 'https://example.test/login', title: 'Login',
|
|
73
|
+
capabilities: capabilities, manage: double('manage', window: window), execute_script: 2,
|
|
74
|
+
bidi: bidi, window_handle: 'context-1')
|
|
75
|
+
end
|
|
76
|
+
let(:wait_helper) do
|
|
77
|
+
Module.new do
|
|
78
|
+
class << self
|
|
79
|
+
attr_accessor :observer
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
let(:stderr) { StringIO.new }
|
|
84
|
+
let(:warnings) { stderr.string.lines.grep(/\[trace\]/) }
|
|
85
|
+
let(:source) { { file: 'spec/login_page_spec.rb', line: 12 } }
|
|
86
|
+
let(:failed_test) { { identifier: 'spec/login_page_spec.rb[1:1]', full_name: 'Login page logs in', status: 'failed', attempt: 2, error: nil } }
|
|
87
|
+
|
|
88
|
+
def render_and_load
|
|
89
|
+
generator = HelpersGenerator.new(%w[selenium rspec proj])
|
|
90
|
+
rendered = ERB.new(File.read(template), trim_mode: '-').result(generator.instance_eval { binding })
|
|
91
|
+
path = File.join(project_dir, 'trace_helper.rb')
|
|
92
|
+
File.write(path, rendered)
|
|
93
|
+
load path
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def started_recorder(**lambdas)
|
|
97
|
+
TraceHelper.recorder_for(driver, **lambdas).tap(&:start)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# What WaitHelper reports for one action. With an element, it was found ready (action_ready).
|
|
101
|
+
def act(recorder, action, selector, status: :passed, error: nil, within: [], args: nil, element: nil)
|
|
102
|
+
token = recorder.action_started(action: action, locator: { strategy: 'id', selector: selector }, within: within,
|
|
103
|
+
source: source, args: args)
|
|
104
|
+
recorder.action_ready(token, element: element) if element
|
|
105
|
+
recorder.action_finished(token, status: status, waited_ms: 95, retries: 1, error: error, element: element)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# What Page.capture's script answers: the inlined CSS, the sheets it could not read and the title.
|
|
109
|
+
def page_script(css: '', skipped: 0, title: 'Login')
|
|
110
|
+
allow(driver).to receive(:execute_script).with(/styleSheets/).and_return('css' => css, 'skipped' => skipped, 'title' => title)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# What Page.element_info's script answers for one element.
|
|
114
|
+
def element_script(element, secret: false, tag: 'input', label: 'Password', role: 'textbox')
|
|
115
|
+
info = { 'rect' => { 'x' => 640, 'y' => 312, 'width' => 320, 'height' => 40 }, 'tag' => tag, 'label' => label,
|
|
116
|
+
'role' => role, 'secret' => secret }
|
|
117
|
+
allow(driver).to receive(:execute_script).with(/getBoundingClientRect/, element).and_return(info)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def snapshot_of(path, index)
|
|
121
|
+
Zip::File.open(path) { |zip| zip.read(format('steps/%04d.html', index)) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def kept(recorder, test = failed_test)
|
|
125
|
+
path = recorder.stop(keep: true, test: test)
|
|
126
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
127
|
+
TraceFormatChecker.read(path).first
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
around do |example|
|
|
131
|
+
original_stderr = $stderr
|
|
132
|
+
$stderr = stderr
|
|
133
|
+
FileUtils.mkdir_p(File.join(project_dir, 'config'))
|
|
134
|
+
File.write(File.join(project_dir, 'config', 'config.yml'), { 'browser' => 'chrome', 'trace' => trace_config }.to_yaml)
|
|
135
|
+
Dir.chdir(project_dir) { example.run }
|
|
136
|
+
ensure
|
|
137
|
+
$stderr = original_stderr
|
|
138
|
+
Object.send(:remove_const, :TraceHelper) if defined?(TraceHelper)
|
|
139
|
+
FileUtils.rm_rf(project_dir)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
before do
|
|
143
|
+
# Recorder tmpdirs land inside the project, which the around hook removes.
|
|
144
|
+
stub_const('ENV', ENV.to_h.merge(env).merge('TMPDIR' => File.join(project_dir, 'tmp')).compact)
|
|
145
|
+
FileUtils.mkdir_p(File.join(project_dir, 'tmp'))
|
|
146
|
+
stub_const('WaitHelper', wait_helper)
|
|
147
|
+
render_and_load
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'writes a zip whose trace.json has format raider-trace, version 1 and the spec §1 top-level keys' do
|
|
151
|
+
recorder = started_recorder
|
|
152
|
+
recorder.navigated('https://example.test/login')
|
|
153
|
+
trace = kept(recorder)
|
|
154
|
+
|
|
155
|
+
expect(trace.keys).to eq(%w[format version producer test browser viewport device_pixel_ratio started_at finished_at
|
|
156
|
+
capabilities truncated steps network console])
|
|
157
|
+
expect(trace).to include('format' => 'raider-trace', 'version' => 1,
|
|
158
|
+
'producer' => { 'name' => 'ruby_raider', 'version' => File.read(File.expand_path('../../lib/version', __dir__)).strip },
|
|
159
|
+
'test' => { 'identifier' => 'spec/login_page_spec.rb[1:1]', 'full_name' => 'Login page logs in',
|
|
160
|
+
'status' => 'failed', 'attempt' => 2, 'error' => nil },
|
|
161
|
+
'browser' => { 'name' => 'chrome', 'version' => '131.0.6778.85' },
|
|
162
|
+
'viewport' => { 'width' => 1920, 'height' => 1080 }, 'device_pixel_ratio' => 2,
|
|
163
|
+
'capabilities' => { 'network' => true, 'console' => true, 'screenshots' => true, 'snapshots' => true },
|
|
164
|
+
'truncated' => { 'steps' => 0, 'screenshots' => 0, 'snapshots' => 0, 'network' => 0, 'console' => 0 })
|
|
165
|
+
expect(trace['finished_at'] >= trace['started_at']).to be(true)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'records navigate and click steps in order with index, kind, action, locator, source, waited_ms, retries' do
|
|
169
|
+
recorder = started_recorder
|
|
170
|
+
recorder.navigated('https://example.test/login')
|
|
171
|
+
act(recorder, :click, 'go', within: [{ strategy: 'css', selector: '.form' }])
|
|
172
|
+
steps = kept(recorder)['steps']
|
|
173
|
+
|
|
174
|
+
expect(steps.map { |step| step.values_at('index', 'kind', 'action') }).to eq([[1, 'navigate', 'navigate'], [2, 'action', 'click']])
|
|
175
|
+
expect(steps.first).to include('parent_index' => nil, 'locator' => nil, 'url' => 'https://example.test/login', 'page_title' => 'Login',
|
|
176
|
+
'waited_ms' => 0, 'retries' => 0, 'status' => 'passed', 'error' => nil, 'rect' => nil,
|
|
177
|
+
'screenshot' => 'steps/0001.jpg', 'snapshot' => 'steps/0001.html')
|
|
178
|
+
# No locator.label: a page object's element method has returned before the action runs, so it is never known.
|
|
179
|
+
expect(steps.last).to include('locator' => { 'strategy' => 'id', 'selector' => 'go',
|
|
180
|
+
'within' => [{ 'strategy' => 'css', 'selector' => '.form' }] },
|
|
181
|
+
'source' => { 'file' => 'spec/login_page_spec.rb', 'line' => 12 },
|
|
182
|
+
'waited_ms' => 95, 'retries' => 1, 'status' => 'passed',
|
|
183
|
+
'screenshot' => 'steps/0002.jpg', 'snapshot' => 'steps/0002.html')
|
|
184
|
+
expect(steps.last['duration_ms']).to be_a(Integer)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'leaves locator.label optional in the format checker: a string is allowed, anything else is a problem' do
|
|
188
|
+
locator = { 'strategy' => 'id', 'selector' => 'password' }
|
|
189
|
+
|
|
190
|
+
expect(TraceFormatChecker.locator_rules('steps[0]', locator)).to eq([])
|
|
191
|
+
expect(TraceFormatChecker.locator_rules('steps[0]', locator.merge('label' => 'password_field'))).to eq([])
|
|
192
|
+
expect(TraceFormatChecker.locator_rules('steps[0]', locator.merge('label' => 3))).to eq(['steps[0].locator.label is not a string'])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'gives query actions a step without screenshot or snapshot' do
|
|
196
|
+
recorder = started_recorder
|
|
197
|
+
recorder.navigated('https://example.test/login')
|
|
198
|
+
%i[text attribute displayed? enabled? selected? find_element find_elements].each { |query| act(recorder, query, 'welcome') }
|
|
199
|
+
path = recorder.stop(keep: true, test: failed_test.merge(status: 'passed'))
|
|
200
|
+
trace, names = TraceFormatChecker.read(path)
|
|
201
|
+
|
|
202
|
+
queries = trace['steps'].drop(1)
|
|
203
|
+
expect(queries.map { |step| step['action'] }).to eq(%w[text attribute displayed? enabled? selected? find_element find_elements])
|
|
204
|
+
expect(queries.map { |step| step.values_at('screenshot', 'snapshot') }.uniq).to eq([[nil, nil]])
|
|
205
|
+
expect(queries.map { |step| step['url'] }.uniq).to eq(['https://example.test/login'])
|
|
206
|
+
expect(names).to contain_exactly('trace.json', 'steps/0001.jpg', 'steps/0001.html')
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'records a Cucumber step as a cucumber_step group carrying its name, with the actions it ran as children' do
|
|
210
|
+
recorder = started_recorder
|
|
211
|
+
group = recorder.step_started(name: 'When I log in as admin', source: source)
|
|
212
|
+
act(recorder, :click, 'login')
|
|
213
|
+
recorder.step_finished(group, status: :passed, error: nil)
|
|
214
|
+
act(recorder, :click, 'outside')
|
|
215
|
+
steps = kept(recorder)['steps']
|
|
216
|
+
|
|
217
|
+
expect(steps.map { |step| step.values_at('index', 'parent_index', 'kind', 'action', 'status', 'name') })
|
|
218
|
+
.to eq([[1, nil, 'group', 'cucumber_step', 'passed', 'When I log in as admin'], [2, 1, 'action', 'click', 'passed', nil],
|
|
219
|
+
[3, nil, 'action', 'click', 'passed', nil]])
|
|
220
|
+
expect(steps[1..].map { |step| step.key?('name') }).to eq([false, false])
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'records a failed Cucumber step with its error' do
|
|
224
|
+
recorder = started_recorder
|
|
225
|
+
group = recorder.step_started(name: 'Then I see the dashboard', source: source)
|
|
226
|
+
recorder.step_finished(group, status: :failed, error: RuntimeError.new('no dashboard'))
|
|
227
|
+
step = kept(recorder)['steps'].first
|
|
228
|
+
|
|
229
|
+
expect(step).to include('status' => 'failed', 'error' => { 'class' => 'RuntimeError', 'message' => 'no dashboard' })
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'keeps only the last pass of eventually as child steps and counts the earlier passes in retries' do
|
|
233
|
+
recorder = started_recorder
|
|
234
|
+
group = recorder.group_started(action: :eventually, source: source)
|
|
235
|
+
2.times do |pass|
|
|
236
|
+
act(recorder, :click, 'refresh')
|
|
237
|
+
act(recorder, :text, 'welcome', status: :failed, error: RuntimeError.new("not yet #{pass}"))
|
|
238
|
+
recorder.group_retried(group, error: RuntimeError.new("not yet #{pass}"))
|
|
239
|
+
end
|
|
240
|
+
act(recorder, :click, 'refresh')
|
|
241
|
+
act(recorder, :text, 'welcome')
|
|
242
|
+
recorder.group_finished(group, status: :passed, retries: 2, error: nil)
|
|
243
|
+
steps = kept(recorder)['steps']
|
|
244
|
+
|
|
245
|
+
expect(steps.map { |step| step.values_at('index', 'parent_index', 'kind', 'action', 'status') })
|
|
246
|
+
.to eq([[1, nil, 'group', 'eventually', 'passed'], [2, 1, 'action', 'click', 'passed'], [3, 1, 'action', 'text', 'passed']])
|
|
247
|
+
expect(steps.first).to include('retries' => 2, 'error' => { 'class' => 'RuntimeError', 'message' => 'not yet 1' }, 'screenshot' => nil, 'snapshot' => nil)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'appends a failure step with the error when no traced step was running' do
|
|
251
|
+
recorder = started_recorder
|
|
252
|
+
recorder.navigated('https://example.test/login')
|
|
253
|
+
act(recorder, :text, 'welcome')
|
|
254
|
+
trace = kept(recorder, failed_test.merge(error: RSpec::Expectations::ExpectationNotMetError.new('expected "Hi"')))
|
|
255
|
+
steps = trace['steps']
|
|
256
|
+
error = { 'class' => 'RSpec::Expectations::ExpectationNotMetError', 'message' => 'expected "Hi"' }
|
|
257
|
+
|
|
258
|
+
expect(trace['test']['error']).to eq(error)
|
|
259
|
+
expect(steps.size).to eq(3)
|
|
260
|
+
expect(steps.last).to include('index' => 3, 'kind' => 'failure', 'status' => 'failed', 'locator' => nil, 'error' => error,
|
|
261
|
+
'screenshot' => 'steps/0003.jpg', 'snapshot' => 'steps/0003.html')
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it 'gives an error of an anonymous class a string class, which the format requires' do
|
|
265
|
+
error = Class.new(StandardError).new('anonymous')
|
|
266
|
+
|
|
267
|
+
expect(TraceHelper.error_json(error)).to eq(class: error.class.to_s, message: 'anonymous')
|
|
268
|
+
expect(TraceHelper.error_json(error)[:class]).to start_with('#<Class:')
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
it 'marks the step that raised as failed and appends no failure step when that step owns the error' do
|
|
272
|
+
recorder = started_recorder
|
|
273
|
+
error = RuntimeError.new('Waited 10s for {id: "go"} to be visible')
|
|
274
|
+
act(recorder, :click, 'go', status: :failed, error: error)
|
|
275
|
+
steps = kept(recorder, failed_test.merge(error: error))['steps']
|
|
276
|
+
|
|
277
|
+
expect(steps.map { |step| step.values_at('kind', 'status', 'error') })
|
|
278
|
+
.to eq([['action', 'failed', { 'class' => 'RuntimeError', 'message' => 'Waited 10s for {id: "go"} to be visible' }]])
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'still gives the test error a step when the step that raised it was dropped by eventually' do
|
|
282
|
+
recorder = started_recorder
|
|
283
|
+
error = RuntimeError.new('not yet')
|
|
284
|
+
group = recorder.group_started(action: :eventually, source: source)
|
|
285
|
+
act(recorder, :click, 'refresh', status: :failed, error: error)
|
|
286
|
+
recorder.group_retried(group, error: error)
|
|
287
|
+
act(recorder, :click, 'refresh')
|
|
288
|
+
recorder.group_finished(group, status: :passed, retries: 1, error: nil)
|
|
289
|
+
steps = kept(recorder, failed_test.merge(error: error))['steps']
|
|
290
|
+
|
|
291
|
+
expect(steps.map { |step| step.values_at('kind', 'status') }).to eq([%w[group passed], %w[action passed]])
|
|
292
|
+
expect(steps.first['error']).to eq('class' => 'RuntimeError', 'message' => 'not yet')
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it 'appends a failure step when the step that raised the test error was dropped by max_steps' do
|
|
296
|
+
allow(TraceHelper).to receive(:config).and_return(trace_config.merge('max_steps' => 2))
|
|
297
|
+
recorder = started_recorder
|
|
298
|
+
error = RuntimeError.new('gone')
|
|
299
|
+
act(recorder, :click, 'first', status: :failed, error: error)
|
|
300
|
+
2.times { act(recorder, :click, 'later') }
|
|
301
|
+
steps = kept(recorder, failed_test.merge(error: error))['steps']
|
|
302
|
+
|
|
303
|
+
expect(steps.map { |step| step['kind'] }).to eq(%w[action failure])
|
|
304
|
+
expect(steps.last['error']).to eq('class' => 'RuntimeError', 'message' => 'gone')
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'records screenshot: null and keeps going when the screenshot lambda raises' do
|
|
308
|
+
recorder = started_recorder(screenshot: ->(_driver) { raise 'browser gone' })
|
|
309
|
+
recorder.navigated('https://example.test/login')
|
|
310
|
+
act(recorder, :click, 'go')
|
|
311
|
+
steps = kept(recorder, failed_test.merge(error: RuntimeError.new('boom')))['steps']
|
|
312
|
+
|
|
313
|
+
expect(steps.map { |step| step['kind'] }).to eq(%w[navigate action failure])
|
|
314
|
+
expect(steps.map { |step| step['screenshot'] }).to eq([nil, nil, nil])
|
|
315
|
+
expect(steps.map { |step| step['snapshot'] }).to eq(%w[steps/0001.html steps/0002.html steps/0003.html])
|
|
316
|
+
expect(warnings).to eq(["[trace] screenshot not captured: browser gone\n"])
|
|
317
|
+
expect(TraceHelper.enabled?).to be(true)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
describe 'snapshots' do
|
|
321
|
+
def sanitize(page, css: '', skipped: 0)
|
|
322
|
+
TraceHelper::Snapshot.sanitize(page, css: css, skipped: skipped)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'strips script, on* handlers, javascript: urls, base and meta refresh' do
|
|
326
|
+
page = <<~HTML
|
|
327
|
+
<html><head><base href="https://example.test/"><meta http-equiv="Refresh" content="0;url=/x"><meta charset="utf-8">
|
|
328
|
+
<script src="/app.js"></script><SCRIPT type="module">alert("<p>")</SCRIPT></head>
|
|
329
|
+
<body onload="go()"><a href=" javascript:alert(1)" class="x">a</a><img src='JavaScript:evil()' alt="i">
|
|
330
|
+
<a href="/ok" onclick='x()'>ok</a><button onClick=go type="button">b</button><p title="a > b">t</p></body></html>
|
|
331
|
+
HTML
|
|
332
|
+
clean = sanitize(page)
|
|
333
|
+
|
|
334
|
+
expect(clean).not_to match(/script|onload|onclick|javascript:|<base|refresh/i)
|
|
335
|
+
expect(clean).to include('<meta charset="utf-8">', '<a class="x">a</a>', '<img alt="i">', '<a href="/ok">ok</a>',
|
|
336
|
+
'<button type="button">b</button>', '<p title="a > b">t</p>', '<body>')
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
it 'inlines the page CSS in place of stylesheet links and counts skipped cross-origin sheets' do
|
|
340
|
+
page_script(css: "body { color: red; }\np::after { content: \"</style>\"; }", skipped: 2)
|
|
341
|
+
allow(driver).to receive(:page_source).and_return(<<~HTML)
|
|
342
|
+
<html lang="en"><head><link rel="stylesheet" href="/a.css"><link href="https://cdn.test/b.css" REL='Stylesheet'>
|
|
343
|
+
<style>p { margin: 0; }</style><link rel="icon" href="/favicon.ico"></head><body><p>hi</p></body></html>
|
|
344
|
+
HTML
|
|
345
|
+
recorder = started_recorder
|
|
346
|
+
recorder.navigated('https://example.test/login')
|
|
347
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
348
|
+
snapshot = snapshot_of(path, 1)
|
|
349
|
+
|
|
350
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
351
|
+
|
|
352
|
+
expect(snapshot.scan(/<style/i).size).to eq(1)
|
|
353
|
+
expect(snapshot).to include('<html lang="en" data-raider-skipped-sheets="2">', 'body { color: red; }',
|
|
354
|
+
'content: "<\\/style>"', '<link rel="icon" href="/favicon.ico">', '<p>hi</p>')
|
|
355
|
+
expect(snapshot).not_to match(/stylesheet|margin: 0/i)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
it 'adds no base element' do
|
|
359
|
+
clean = sanitize('<html><head><title>t</title></head><body><a href="/x">x</a></body></html>', css: 'a { color: red; }')
|
|
360
|
+
|
|
361
|
+
expect(clean).not_to match(/<base/i)
|
|
362
|
+
expect(clean).to include('<html data-raider-skipped-sheets="0">', '<style>a { color: red; }</style></head>')
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it 'counts the stylesheet links it removed as skipped, and keeps <style>, when the CSS could not be read' do
|
|
366
|
+
page = '<html data-raider-skipped-sheets="9" lang="en"><head><link rel="stylesheet" href="/a.css"><link rel="stylesheet" href="/b.css">' \
|
|
367
|
+
'<style>p { margin: 0; }</style></head><body></body></html>'
|
|
368
|
+
|
|
369
|
+
expect(sanitize(page, css: nil)).to eq('<html lang="en" data-raider-skipped-sheets="2"><head><style>p { margin: 0; }</style></head><body></body></html>')
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
describe 'typed values and element info' do
|
|
374
|
+
let(:field) { double('field') }
|
|
375
|
+
|
|
376
|
+
before { page_script }
|
|
377
|
+
|
|
378
|
+
def typed_step(recorder, args, action: :send_keys)
|
|
379
|
+
act(recorder, action, 'field', args: args, element: field)
|
|
380
|
+
kept(recorder)['steps'].last
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
it 'masks a password field: value null, secret true, and a blank value attribute in the snapshot' do
|
|
384
|
+
element_script(field, secret: true)
|
|
385
|
+
allow(driver).to receive(:page_source).and_return('<html><body><input type="password" name="pw" value="hunter2"></body></html>')
|
|
386
|
+
recorder = started_recorder
|
|
387
|
+
act(recorder, :send_keys, 'pw', args: ['hunter2'], element: field)
|
|
388
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
389
|
+
step = TraceFormatChecker.read(path).first['steps'].last
|
|
390
|
+
|
|
391
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
392
|
+
expect(step).to include('value' => nil, 'secret' => true)
|
|
393
|
+
expect(snapshot_of(path, 1)).to include('<input type="password" name="pw" value="">')
|
|
394
|
+
expect(Zip::File.open(path) { |zip| zip.entries.map { |entry| zip.read(entry.name) }.join }).not_to include('hunter2')
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
it 'keeps a masked value out of later snapshots after the page switches the field to type=text' do
|
|
398
|
+
element_script(field, secret: true)
|
|
399
|
+
allow(driver).to receive(:page_source).and_return('<html><body><input type="text" name="pw" value="hunter2"></body></html>')
|
|
400
|
+
recorder = started_recorder
|
|
401
|
+
act(recorder, :send_keys, 'pw', args: ['hunter2'], element: field)
|
|
402
|
+
act(recorder, :click, 'show-password', element: field)
|
|
403
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
404
|
+
|
|
405
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
406
|
+
expect(snapshot_of(path, 2)).to include('<input type="text" name="pw" value="">')
|
|
407
|
+
expect(Zip::File.open(path) { |zip| zip.entries.map { |entry| zip.read(entry.name) } }).to all(satisfy { |bytes| !bytes.include?('hunter2') })
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it 'masks a type=text field whose autocomplete contains password' do
|
|
411
|
+
element_script(field, secret: true)
|
|
412
|
+
clean = TraceHelper::Snapshot.sanitize(
|
|
413
|
+
"<input value='s3cret' AUTOCOMPLETE=\"New-Password\" type=text><input value=keep type=text autocomplete=username>",
|
|
414
|
+
css: nil, skipped: 0
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
expect(typed_step(started_recorder, ['s3cret'])).to include('value' => nil, 'secret' => true)
|
|
418
|
+
expect(clean).to eq('<input value="" AUTOCOMPLETE="New-Password" type=text><input value=keep type=text autocomplete=username>')
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
context 'with a RAIDER_SECRET_* value set' do
|
|
422
|
+
let(:env) { { 'RAIDER_SECRET_ADMIN' => 'pa55word' } }
|
|
423
|
+
|
|
424
|
+
it 'masks a value equal to a RAIDER_SECRET_* env value typed into a plain field' do
|
|
425
|
+
element_script(field, secret: false, label: 'Notes')
|
|
426
|
+
allow(driver).to receive(:page_source).and_return('<html><body><input type="text" name="notes" value="pa55word"></body></html>')
|
|
427
|
+
recorder = started_recorder
|
|
428
|
+
act(recorder, :send_keys, 'notes', args: ['pa55word'], element: field)
|
|
429
|
+
act(recorder, :send_keys, 'notes', args: ['pa55word', :enter], element: field)
|
|
430
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
431
|
+
steps = TraceFormatChecker.read(path).first['steps']
|
|
432
|
+
|
|
433
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
434
|
+
expect(steps.map { |step| step.values_at('value', 'secret') }).to eq([[nil, true], [nil, true]])
|
|
435
|
+
expect(snapshot_of(path, 1)).to include('<input type="text" name="notes" value="">')
|
|
436
|
+
expect(Zip::File.open(path) { |zip| zip.entries.map { |entry| zip.read(entry.name) } }).to all(satisfy { |bytes| !bytes.include?('pa55word') })
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
context 'with a RAIDER_SECRET_* value holding & and "' do
|
|
441
|
+
let(:env) { { 'RAIDER_SECRET_TOKEN' => 'a&b"c' } }
|
|
442
|
+
|
|
443
|
+
it 'blanks the value attribute the browser wrote entity-encoded, and keeps it out of every zip entry' do
|
|
444
|
+
element_script(field, secret: false, label: 'Notes')
|
|
445
|
+
allow(driver).to receive(:page_source).and_return('<html><body><input type="text" name="notes" value="a&b"c"></body></html>')
|
|
446
|
+
recorder = started_recorder
|
|
447
|
+
act(recorder, :send_keys, 'notes', args: ['a&b"c'], element: field)
|
|
448
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
449
|
+
entries = Zip::File.open(path) { |zip| zip.entries.map { |entry| zip.read(entry.name) } }
|
|
450
|
+
|
|
451
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
452
|
+
expect(snapshot_of(path, 1)).to include('<input type="text" name="notes" value="">')
|
|
453
|
+
expect(entries).to all(satisfy { |bytes| !bytes.include?('a&b"c') && !bytes.include?('a&b"c') && !bytes.include?('a&b\\"c') })
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
it 'keeps other typed values as typed' do
|
|
458
|
+
element_script(field, secret: false)
|
|
459
|
+
recorder = started_recorder
|
|
460
|
+
act(recorder, :send_keys, 'user', args: ['admin', :enter], element: field)
|
|
461
|
+
act(recorder, :select_by, 'colour', args: [:text, 'Blue'], element: field)
|
|
462
|
+
act(recorder, :click, 'go', element: field)
|
|
463
|
+
steps = kept(recorder)['steps']
|
|
464
|
+
|
|
465
|
+
expect(steps.map { |step| step.values_at('value', 'secret') }).to eq([['admin{enter}', false], ['Blue', false], [nil, nil]])
|
|
466
|
+
expect(steps.last.keys).not_to include('value', 'secret')
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
it 'records rect, element tag, label and role from element_info' do
|
|
470
|
+
element_script(field, tag: 'button', label: 'Log in', role: 'button')
|
|
471
|
+
recorder = started_recorder
|
|
472
|
+
act(recorder, :click, 'go', element: field)
|
|
473
|
+
act(recorder, :text, 'go', element: field)
|
|
474
|
+
steps = kept(recorder)['steps']
|
|
475
|
+
|
|
476
|
+
expect(steps.first).to include('rect' => { 'x' => 640, 'y' => 312, 'width' => 320, 'height' => 40 },
|
|
477
|
+
'element' => { 'tag' => 'button', 'label' => 'Log in', 'role' => 'button' })
|
|
478
|
+
expect(steps.first.keys.first(8)).to eq(%w[index parent_index kind action locator element url page_title])
|
|
479
|
+
expect(steps.last).to include('rect' => nil)
|
|
480
|
+
expect(steps.last.keys).not_to include('element')
|
|
481
|
+
expect(driver).to have_received(:execute_script).with(/getBoundingClientRect/, field).once
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
it 'skips the screenshot while an alert is open' do
|
|
485
|
+
alert = { open: true }
|
|
486
|
+
stub_const('VideoHelper', Module.new { define_singleton_method(:alert_open?) { |_driver| alert[:open] } })
|
|
487
|
+
shots = []
|
|
488
|
+
recorder = started_recorder(screenshot: ->(_driver) { shots << :shot && [png, 'png'] })
|
|
489
|
+
act(recorder, :click, 'go', element: field)
|
|
490
|
+
alert[:open] = false
|
|
491
|
+
act(recorder, :click, 'ok', element: field)
|
|
492
|
+
steps = kept(recorder)['steps']
|
|
493
|
+
|
|
494
|
+
expect(steps.map { |step| step.values_at('screenshot', 'snapshot') }).to eq([[nil, nil], ['steps/0002.png', 'steps/0002.html']])
|
|
495
|
+
expect(shots).to eq([:shot])
|
|
496
|
+
expect(driver).to have_received(:page_source).once
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
describe 'size cap' do
|
|
501
|
+
let(:shot_bytes) { 4000 }
|
|
502
|
+
|
|
503
|
+
# 15 click steps with incompressible media, and 40 network entries.
|
|
504
|
+
def capped(max_mb)
|
|
505
|
+
allow(TraceHelper).to receive(:config).and_return(trace_config.merge('max_mb' => max_mb))
|
|
506
|
+
recorder = started_recorder(screenshot: ->(_driver) { [Random.bytes(shot_bytes), 'png'] },
|
|
507
|
+
snapshot: ->(_driver) { Random.bytes(3000).unpack1('H*') })
|
|
508
|
+
15.times do |step|
|
|
509
|
+
act(recorder, :click, "button-#{step}")
|
|
510
|
+
3.times { |request| recorder.network_event(method: 'GET', url: "https://example.test/#{step}/#{request}?q=#{Random.new((step * 3) + request).bytes(16).unpack1('H*')}", status: 200) }
|
|
511
|
+
end
|
|
512
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
513
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
514
|
+
[TraceFormatChecker.read(path).first, File.size(path), path]
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def nulls(trace, key)
|
|
518
|
+
trace['steps'].map { |step| step[key].nil? }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
it 'stays under max_mb, dropping oldest HTML, then oldest screenshots, then oldest network and console, never the last 10 steps, and counts drops in truncated' do
|
|
522
|
+
full, full_size, full_path = capped(20)
|
|
523
|
+
html_size, old_media = Zip::File.open(full_path) do |zip|
|
|
524
|
+
old = zip.entries.select { |entry| entry.name < 'steps/0006' && entry.name != 'trace.json' }
|
|
525
|
+
[zip.find_entry('steps/0001.html').compressed_size, old.sum { |entry| entry.compressed_size + 76 + (2 * entry.name.bytesize) }]
|
|
526
|
+
end
|
|
527
|
+
expect(full['truncated'].values.sum).to eq(0)
|
|
528
|
+
|
|
529
|
+
limits = { html: full_size - (1.5 * html_size), shots: full_size - (5 * html_size) - (1.5 * shot_bytes),
|
|
530
|
+
events: full_size - old_media - 300 }
|
|
531
|
+
traces = limits.transform_values do |bytes|
|
|
532
|
+
trace, size, = capped(bytes / 1024.0 / 1024)
|
|
533
|
+
expect(size).to be <= bytes
|
|
534
|
+
trace
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
expect(nulls(traces[:html], 'snapshot')).to eq([true] * 2 + [false] * 13)
|
|
538
|
+
expect(nulls(traces[:html], 'screenshot')).to eq([false] * 15)
|
|
539
|
+
expect(nulls(traces[:shots], 'snapshot')).to eq([true] * 5 + [false] * 10)
|
|
540
|
+
expect(nulls(traces[:shots], 'screenshot')).to eq([true] * 2 + [false] * 13)
|
|
541
|
+
expect(nulls(traces[:events], 'screenshot')).to eq([true] * 5 + [false] * 10)
|
|
542
|
+
dropped = traces[:events]['truncated']['network']
|
|
543
|
+
expect(dropped).to be_positive
|
|
544
|
+
expect(traces[:events]['network'].map { |entry| entry['url'] }).to eq(full['network'].drop(dropped).map { |entry| entry['url'] })
|
|
545
|
+
expect(traces.values.map { |trace| trace['truncated'].values_at('snapshots', 'screenshots') }).to eq([[2, 0], [5, 2], [5, 5]])
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
it 'treats max_steps above 9999 as 9999, so step entries stay steps/NNNN, and a non-positive one as the default' do
|
|
550
|
+
{ 50_000 => 9999, 9999 => 9999, 1 => 1, 0 => 200, -3 => 200 }.each do |configured, used|
|
|
551
|
+
allow(TraceHelper).to receive(:config).and_return(trace_config.merge('max_steps' => configured))
|
|
552
|
+
expect(TraceHelper.max_steps).to eq(used), "max_steps: #{configured}"
|
|
553
|
+
end
|
|
554
|
+
expect(warnings).to eq([])
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
it 'keeps at most max_steps steps, newest kept' do
|
|
558
|
+
allow(TraceHelper).to receive(:config).and_return(trace_config.merge('max_steps' => 5))
|
|
559
|
+
recorder = started_recorder
|
|
560
|
+
8.times { |step| act(recorder, :click, "button-#{step}") }
|
|
561
|
+
path = recorder.stop(keep: true, test: failed_test.merge(status: 'passed'))
|
|
562
|
+
trace, names = TraceFormatChecker.read(path)
|
|
563
|
+
|
|
564
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
565
|
+
expect(trace['steps'].map { |step| [step['index'], step['locator']['selector']] })
|
|
566
|
+
.to eq([[1, 'button-3'], [2, 'button-4'], [3, 'button-5'], [4, 'button-6'], [5, 'button-7']])
|
|
567
|
+
expect(trace['truncated']['steps']).to eq(3)
|
|
568
|
+
expect(names.size).to eq(11)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
it 'returns nil and writes nothing for stop(keep: false)' do
|
|
572
|
+
recorder = started_recorder
|
|
573
|
+
recorder.navigated('https://example.test/login')
|
|
574
|
+
|
|
575
|
+
expect(recorder.stop(keep: false, test: failed_test)).to be_nil
|
|
576
|
+
expect(Dir.glob(File.join(project_dir, 'tmp', '**', 'trace.zip'))).to be_empty
|
|
577
|
+
recorder.cleanup
|
|
578
|
+
expect(Dir.children(File.join(project_dir, 'tmp'))).to be_empty
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
it 'makes the recorder the WaitHelper observer until cleanup, so no test inherits it' do
|
|
582
|
+
recorder = started_recorder
|
|
583
|
+
expect(WaitHelper.observer).to be(recorder)
|
|
584
|
+
|
|
585
|
+
recorder.stop(keep: false, test: failed_test)
|
|
586
|
+
recorder.cleanup
|
|
587
|
+
expect(WaitHelper.observer).to be_nil
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
it 'records navigation and the failure without WaitHelper' do
|
|
591
|
+
hide_const('WaitHelper')
|
|
592
|
+
recorder = started_recorder
|
|
593
|
+
recorder.navigated('https://example.test/login')
|
|
594
|
+
steps = kept(recorder, failed_test.merge(error: RuntimeError.new('boom')))['steps']
|
|
595
|
+
|
|
596
|
+
expect(steps.map { |step| step['kind'] }).to eq(%w[navigate failure])
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
context 'with TRACE=off' do
|
|
600
|
+
let(:env) { { 'TRACE' => 'off' } }
|
|
601
|
+
|
|
602
|
+
it 'returns a NullRecorder and leaves WaitHelper.observer nil with TRACE=off or enabled: false' do
|
|
603
|
+
recorder = TraceHelper.recorder_for(driver)
|
|
604
|
+
|
|
605
|
+
expect(recorder).to be_a(TraceHelper::NullRecorder)
|
|
606
|
+
expect(recorder.start).to be_nil
|
|
607
|
+
expect(recorder.stop(keep: true, test: failed_test)).to be_nil
|
|
608
|
+
expect(WaitHelper.observer).to be_nil
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
context 'with enabled: false' do
|
|
613
|
+
let(:trace_config) { { 'enabled' => false } }
|
|
614
|
+
|
|
615
|
+
it 'returns a NullRecorder and leaves WaitHelper.observer nil with TRACE=off or enabled: false' do
|
|
616
|
+
expect(TraceHelper.recorder_for(driver)).to be_a(TraceHelper::NullRecorder)
|
|
617
|
+
expect(WaitHelper.observer).to be_nil
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
context 'with TRACE=on' do
|
|
622
|
+
let(:env) { { 'TRACE' => 'on' } }
|
|
623
|
+
|
|
624
|
+
it 'keeps every trace' do
|
|
625
|
+
expect(TraceHelper.keep_all?).to be(true)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
it 'keeps failing traces only with TRACE unset' do
|
|
630
|
+
expect(TraceHelper.keep_all?).to be(false)
|
|
631
|
+
expect(TraceHelper.config).to eq(trace_config)
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
it 'stores images uncompressed and JSON/HTML deflated, readable by rubyzip, with steps/NNNN names' do
|
|
635
|
+
recorder = started_recorder
|
|
636
|
+
recorder.navigated('https://example.test/login')
|
|
637
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
638
|
+
|
|
639
|
+
Zip::File.open(path) do |zip|
|
|
640
|
+
methods = zip.entries.to_h { |entry| [entry.name, entry.compression_method] }
|
|
641
|
+
expect(methods).to eq('trace.json' => Zip::Entry::DEFLATED, 'steps/0001.jpg' => Zip::Entry::STORED,
|
|
642
|
+
'steps/0001.html' => Zip::Entry::DEFLATED)
|
|
643
|
+
expect(zip.read('steps/0001.jpg')).to eq(jpeg)
|
|
644
|
+
expect(zip.read('steps/0001.html')).to eq(html.sub('<html>', '<html data-raider-skipped-sheets="0">'))
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
it 'prints the no-BiDi notice and then the disable line when a Safari run fails to write its zip' do
|
|
649
|
+
allow(capabilities).to receive(:browser_name).and_return('Safari')
|
|
650
|
+
allow(TraceHelper::ZipWriter).to receive(:write).and_raise(Errno::ENOSPC)
|
|
651
|
+
2.times do
|
|
652
|
+
recorder = started_recorder
|
|
653
|
+
recorder.navigated('https://example.test/login')
|
|
654
|
+
recorder.stop(keep: true, test: failed_test)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
expect(warnings).to eq(["[trace] network and console not captured: Safari has no WebDriver BiDi\n",
|
|
658
|
+
"[trace] could not write the trace: No space left on device; tracing disabled for this run\n"])
|
|
659
|
+
expect(TraceHelper.enabled?).to be(false)
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
it 'prints a media miss and then the disable line, at most one of each' do
|
|
663
|
+
allow(TraceHelper::ZipWriter).to receive(:write).and_raise(Errno::ENOSPC)
|
|
664
|
+
recorder = started_recorder(screenshot: ->(_driver) { raise 'browser gone' })
|
|
665
|
+
2.times { recorder.navigated('https://example.test/login') }
|
|
666
|
+
recorder.stop(keep: true, test: failed_test)
|
|
667
|
+
TraceHelper.disable!('a second reason')
|
|
668
|
+
|
|
669
|
+
expect(warnings).to eq(["[trace] screenshot not captured: browser gone\n",
|
|
670
|
+
"[trace] could not write the trace: No space left on device; tracing disabled for this run\n"])
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
it 'turns tracing off for the run after one [trace] warning when writing the zip fails' do
|
|
674
|
+
allow(TraceHelper::ZipWriter).to receive(:write).and_raise(Errno::ENOSPC)
|
|
675
|
+
2.times do
|
|
676
|
+
recorder = started_recorder
|
|
677
|
+
recorder.navigated('https://example.test/login')
|
|
678
|
+
expect(recorder.stop(keep: true, test: failed_test)).to be_nil
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
expect(warnings).to eq(["[trace] could not write the trace: No space left on device; tracing disabled for this run\n"])
|
|
682
|
+
expect(TraceHelper.enabled?).to be(false)
|
|
683
|
+
expect(TraceHelper.recorder_for(driver)).to be_a(TraceHelper::NullRecorder)
|
|
684
|
+
expect(WaitHelper.observer).to be_nil
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
describe 'browser wiring' do
|
|
688
|
+
let(:started_ms) { 1_793_568_600_020 }
|
|
689
|
+
let(:bidi_events) { %w[network.beforeRequestSent network.responseCompleted network.fetchError log.entryAdded] }
|
|
690
|
+
|
|
691
|
+
def request_sent(id, method, url, at)
|
|
692
|
+
bidi.emit('network.beforeRequestSent',
|
|
693
|
+
'request' => { 'request' => id, 'method' => method, 'url' => url,
|
|
694
|
+
'headers' => [{ 'name' => 'Cookie', 'value' => { 'type' => 'string', 'value' => 'session=hush' } }] },
|
|
695
|
+
'timestamp' => at)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
it 'subscribes passively to the four BiDi events and never adds a request handler' do
|
|
699
|
+
first = started_recorder
|
|
700
|
+
first.stop(keep: false, test: failed_test)
|
|
701
|
+
second = started_recorder
|
|
702
|
+
bidi.emit('log.entryAdded', 'type' => 'console', 'level' => 'info', 'text' => 'second test', 'timestamp' => started_ms)
|
|
703
|
+
|
|
704
|
+
expect(bidi.subscribed).to eq([bidi_events])
|
|
705
|
+
expect(bidi.callbacks.keys).to match_array(bidi_events)
|
|
706
|
+
expect(bidi.commands.map(&:first)).not_to include('network.addIntercept')
|
|
707
|
+
expect(kept(second)['console'].map { |entry| entry['text'] }).to eq(['second test'])
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
it 'maps beforeRequestSent and responseCompleted into one network entry with step_index, method, url, status, mime_type, duration_ms, bytes' do
|
|
711
|
+
recorder = started_recorder
|
|
712
|
+
recorder.navigated('https://example.test/login')
|
|
713
|
+
request_sent('r1', 'GET', 'https://example.test/app.js', started_ms)
|
|
714
|
+
act(recorder, :click, 'go')
|
|
715
|
+
bidi.emit('network.responseCompleted',
|
|
716
|
+
'request' => { 'request' => 'r1', 'url' => 'https://example.test/app.js' }, 'timestamp' => started_ms + 210,
|
|
717
|
+
'response' => { 'status' => 200, 'mimeType' => 'text/javascript', 'bytesReceived' => 18_234,
|
|
718
|
+
'headers' => [{ 'name' => 'Set-Cookie', 'value' => { 'type' => 'string', 'value' => 'session=hush' } }] })
|
|
719
|
+
request_sent('r2', 'POST', 'https://example.test/api', started_ms + 300)
|
|
720
|
+
bidi.emit('network.fetchError', 'request' => { 'request' => 'r2' }, 'errorText' => 'net::ERR_CONNECTION_REFUSED',
|
|
721
|
+
'timestamp' => started_ms + 305)
|
|
722
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
723
|
+
|
|
724
|
+
expect(TraceFormatChecker.problems(path)).to eq([])
|
|
725
|
+
expect(TraceFormatChecker.read(path).first['network']).to eq(
|
|
726
|
+
[{ 'step_index' => 1, 'method' => 'GET', 'url' => 'https://example.test/app.js', 'status' => 200,
|
|
727
|
+
'mime_type' => 'text/javascript', 'started_at' => '2026-11-01T21:30:00.020Z', 'duration_ms' => 210,
|
|
728
|
+
'bytes' => 18_234, 'error' => nil },
|
|
729
|
+
{ 'step_index' => 2, 'method' => 'POST', 'url' => 'https://example.test/api', 'status' => nil, 'mime_type' => nil,
|
|
730
|
+
'started_at' => '2026-11-01T21:30:00.320Z', 'duration_ms' => 5, 'bytes' => nil,
|
|
731
|
+
'error' => 'net::ERR_CONNECTION_REFUSED' }]
|
|
732
|
+
)
|
|
733
|
+
expect(Zip::File.open(path) { |zip| zip.read('trace.json') }).not_to include('hush')
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
it 'completes a network entry whose responseCompleted thread ran before its beforeRequestSent' do
|
|
737
|
+
recorder = started_recorder
|
|
738
|
+
bidi.emit('network.responseCompleted', 'request' => { 'request' => 'r1' }, 'timestamp' => started_ms + 40,
|
|
739
|
+
'response' => { 'status' => 304, 'mimeType' => 'text/css', 'bytesReceived' => 0 })
|
|
740
|
+
request_sent('r1', 'GET', 'https://example.test/app.css', started_ms)
|
|
741
|
+
|
|
742
|
+
expect(kept(recorder)['network']).to eq(
|
|
743
|
+
[{ 'step_index' => 1, 'method' => 'GET', 'url' => 'https://example.test/app.css', 'status' => 304,
|
|
744
|
+
'mime_type' => 'text/css', 'started_at' => '2026-11-01T21:30:00.020Z', 'duration_ms' => 40, 'bytes' => 0,
|
|
745
|
+
'error' => nil }]
|
|
746
|
+
)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Seen in Chrome 156: responseCompleted carries the request's start as its timestamp, and the end only in
|
|
750
|
+
# request.timings (ms after timeOrigin). Firefox stamps the event's own time and gives absolute timings.
|
|
751
|
+
it "takes duration_ms from the request's timings when responseCompleted is stamped with the request's start" do
|
|
752
|
+
recorder = started_recorder
|
|
753
|
+
request_sent('chrome', 'GET', 'https://example.test/api', started_ms)
|
|
754
|
+
bidi.emit('network.responseCompleted', 'timestamp' => started_ms, 'response' => { 'status' => 200 },
|
|
755
|
+
'request' => { 'request' => 'chrome',
|
|
756
|
+
'timings' => { 'timeOrigin' => started_ms, 'responseEnd' => 310.449 } })
|
|
757
|
+
request_sent('firefox', 'GET', 'https://example.test/api', started_ms)
|
|
758
|
+
bidi.emit('network.responseCompleted', 'timestamp' => started_ms + 301, 'response' => { 'status' => 200 },
|
|
759
|
+
'request' => { 'request' => 'firefox',
|
|
760
|
+
'timings' => { 'timeOrigin' => 0, 'responseEnd' => started_ms + 300.6 } })
|
|
761
|
+
|
|
762
|
+
expect(kept(recorder)['network'].map { |entry| entry['duration_ms'] }).to eq([310, 301])
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
it 'maps log.entryAdded into a console entry with level, text, source, at' do
|
|
766
|
+
recorder = started_recorder
|
|
767
|
+
recorder.navigated('https://example.test/login')
|
|
768
|
+
bidi.emit('log.entryAdded',
|
|
769
|
+
'type' => 'javascript', 'level' => 'error', 'text' => 'Uncaught TypeError: x is undefined',
|
|
770
|
+
'timestamp' => started_ms, 'source' => { 'realm' => 'realm-1', 'context' => 'context-1' },
|
|
771
|
+
'stackTrace' => { 'callFrames' => [{ 'url' => 'https://example.test/app.js', 'functionName' => 'go',
|
|
772
|
+
'lineNumber' => 40, 'columnNumber' => 6 }] })
|
|
773
|
+
bidi.emit('log.entryAdded', 'type' => 'console', 'method' => 'log', 'level' => 'info', 'text' => 'ready',
|
|
774
|
+
'timestamp' => started_ms + 1, 'source' => { 'realm' => 'realm-1' }, 'args' => [])
|
|
775
|
+
|
|
776
|
+
expect(kept(recorder)['console']).to eq(
|
|
777
|
+
[{ 'step_index' => 1, 'level' => 'error', 'text' => 'Uncaught TypeError: x is undefined',
|
|
778
|
+
'source' => 'https://example.test/app.js:41:7', 'at' => '2026-11-01T21:30:00.020Z' },
|
|
779
|
+
{ 'step_index' => 1, 'level' => 'info', 'text' => 'ready', 'source' => nil, 'at' => '2026-11-01T21:30:00.021Z' }]
|
|
780
|
+
)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
it 'records capabilities network/console false and warns once when subscribe raises' do
|
|
784
|
+
bidi.failure = Selenium::WebDriver::Error::WebDriverError.new('no such frame')
|
|
785
|
+
traces = Array.new(2) do
|
|
786
|
+
recorder = started_recorder
|
|
787
|
+
recorder.navigated('https://example.test/login')
|
|
788
|
+
kept(recorder)
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
expect(traces.map { |trace| trace['capabilities'] }).to all(include('network' => false, 'console' => false))
|
|
792
|
+
expect(traces.map { |trace| trace['steps'].first['screenshot'] }).to eq(%w[steps/0001.png steps/0001.png])
|
|
793
|
+
expect(warnings).to eq(["[trace] network and console not captured: BiDi subscribe failed: no such frame\n"])
|
|
794
|
+
expect(TraceHelper.enabled?).to be(true)
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
it 'names Edge edge, as Selenium reports it MicrosoftEdge, and attaches BiDi to it' do
|
|
798
|
+
allow(capabilities).to receive(:browser_name).and_return('MicrosoftEdge')
|
|
799
|
+
recorder = started_recorder
|
|
800
|
+
recorder.navigated('https://example.test/login')
|
|
801
|
+
trace = kept(recorder)
|
|
802
|
+
|
|
803
|
+
expect(trace['browser']).to eq('name' => 'edge', 'version' => '131.0.6778.85')
|
|
804
|
+
expect(trace['capabilities']).to include('network' => true, 'console' => true)
|
|
805
|
+
expect(warnings).to eq([])
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
it 'records network/console false and warns once for a browser without BiDi' do
|
|
809
|
+
allow(capabilities).to receive(:browser_name).and_return('Safari')
|
|
810
|
+
recorder = started_recorder
|
|
811
|
+
recorder.navigated('https://example.test/login')
|
|
812
|
+
trace = kept(recorder)
|
|
813
|
+
|
|
814
|
+
expect(trace['capabilities']).to include('network' => false, 'console' => false)
|
|
815
|
+
expect(bidi.subscribed).to eq([])
|
|
816
|
+
expect(warnings).to eq(["[trace] network and console not captured: Safari has no WebDriver BiDi\n"])
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
it 'takes a JPEG through BiDi and a PNG without it' do
|
|
820
|
+
recorder = started_recorder
|
|
821
|
+
recorder.navigated('https://example.test/login')
|
|
822
|
+
path = recorder.stop(keep: true, test: failed_test)
|
|
823
|
+
|
|
824
|
+
expect(Zip::File.open(path) { |zip| zip.read('steps/0001.jpg') }).to eq(jpeg)
|
|
825
|
+
expect(bidi.commands).to eq([['browsingContext.captureScreenshot',
|
|
826
|
+
{ context: 'context-1', origin: 'viewport', format: { type: 'image/jpeg', quality: 0.7 } }]])
|
|
827
|
+
expect(TraceHelper::Screenshot.take(driver, bidi: true)).to eq([jpeg, 'jpg'])
|
|
828
|
+
expect(TraceHelper::Screenshot.take(driver, bidi: false)).to eq([png, 'png'])
|
|
829
|
+
bidi.screenshot = Selenium::WebDriver::Error::WebDriverError.new('unsupported operation')
|
|
830
|
+
expect(TraceHelper::Screenshot.take(driver, bidi: true)).to eq([png, 'png'])
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
it 'keeps every network entry when callbacks arrive from many threads at once' do
|
|
834
|
+
recorder = started_recorder
|
|
835
|
+
Array.new(8) do |thread|
|
|
836
|
+
Thread.new do
|
|
837
|
+
25.times { |request| recorder.network_event({ method: 'GET', url: "https://example.test/#{thread}/#{request}" }) }
|
|
838
|
+
end
|
|
839
|
+
end.each(&:join)
|
|
840
|
+
|
|
841
|
+
expect(kept(recorder)['network'].size).to eq(200)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
it 'is true for chrome, edge and firefox only' do
|
|
845
|
+
expect(%w[chrome edge MicrosoftEdge firefox safari ie].map { |browser| TraceHelper.bidi?(browser) })
|
|
846
|
+
.to eq([true, true, true, true, false, false])
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
context 'with TRACE=off' do
|
|
850
|
+
let(:env) { { 'TRACE' => 'off' } }
|
|
851
|
+
|
|
852
|
+
it 'is false for every browser, and gives no listener' do
|
|
853
|
+
expect(%w[chrome edge firefox].map { |browser| TraceHelper.bidi?(browser) }).to eq([false, false, false])
|
|
854
|
+
expect(TraceHelper.listener).to be_nil
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# The rendered driver_helper.rb with its fan-out listener and BiDi option, loaded next to
|
|
860
|
+
# the rendered video_helper.rb and the trace_helper.rb loaded above.
|
|
861
|
+
describe 'the driver helper' do
|
|
862
|
+
let(:host) { Object.new.extend(DriverHelper) }
|
|
863
|
+
let(:calls) { [] }
|
|
864
|
+
let(:video_listener) do
|
|
865
|
+
seen = calls
|
|
866
|
+
Class.new(Selenium::WebDriver::Support::AbstractEventListener) do
|
|
867
|
+
define_method(:after_navigate_to) { |url, _driver| seen << [:video, url] }
|
|
868
|
+
define_method(:after_click) { |_element, _driver| seen << %i[video click] }
|
|
869
|
+
end.new
|
|
870
|
+
end
|
|
871
|
+
let(:trace_feature) { File.realpath(File.join(project_dir, 'trace_helper.rb')) }
|
|
872
|
+
|
|
873
|
+
def load_driver_helper
|
|
874
|
+
generator = HelpersGenerator.new(%w[selenium rspec proj])
|
|
875
|
+
%w[driver_helper video_helper].each do |name|
|
|
876
|
+
source = File.read(File.expand_path("../../lib/generators/templates/helpers/#{name}.tt", __dir__))
|
|
877
|
+
File.write(File.join(project_dir, "#{name}.rb"), ERB.new(source, trim_mode: '-').result(generator.instance_eval { binding }))
|
|
878
|
+
end
|
|
879
|
+
$LOADED_FEATURES << trace_feature
|
|
880
|
+
load File.join(project_dir, 'driver_helper.rb')
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def write_config(browser)
|
|
884
|
+
config = { 'browser' => browser, 'browser_arguments' => { browser => [] }, 'trace' => trace_config }
|
|
885
|
+
File.write(File.join(project_dir, 'config', 'config.yml'), config.to_yaml)
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def options_for(browser)
|
|
889
|
+
stub_const("Selenium::WebDriver::#{browser.capitalize}::Options", Class.new do
|
|
890
|
+
attr_accessor :web_socket_url
|
|
891
|
+
|
|
892
|
+
def add_argument(_argument); end
|
|
893
|
+
end)
|
|
894
|
+
write_config(browser)
|
|
895
|
+
host.instance_variable_set(:@config, YAML.load_file('config/config.yml'))
|
|
896
|
+
host.send(:create_webdriver_options)
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def created_arguments
|
|
900
|
+
write_config('chrome')
|
|
901
|
+
allow(host).to receive(:create_webdriver_options).and_return(:options)
|
|
902
|
+
allow(Selenium::WebDriver).to receive(:for) { |_browser, **arguments| arguments }
|
|
903
|
+
host.send(:create_driver)
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
before { load_driver_helper }
|
|
907
|
+
|
|
908
|
+
after do
|
|
909
|
+
%i[DriverHelper VideoHelper].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) }
|
|
910
|
+
$LOADED_FEATURES.delete(trace_feature)
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
it 'fans navigation out to both video and trace listeners' do
|
|
914
|
+
allow(VideoHelper).to receive(:listener).and_return(video_listener)
|
|
915
|
+
allow(VideoHelper).to receive(:alert_open?).and_return(false)
|
|
916
|
+
listener = created_arguments[:listener]
|
|
917
|
+
recorder = started_recorder
|
|
918
|
+
listener.after_navigate_to('https://example.test/login', driver)
|
|
919
|
+
listener.after_navigate_back(driver)
|
|
920
|
+
listener.after_click(:element, driver)
|
|
921
|
+
|
|
922
|
+
expect(listener).to be_a(Selenium::WebDriver::Support::AbstractEventListener)
|
|
923
|
+
expect(calls).to eq([[:video, 'https://example.test/login'], %i[video click]])
|
|
924
|
+
expect(kept(recorder)['steps'].map { |step| step.values_at('kind', 'url') })
|
|
925
|
+
.to eq([['navigate', 'https://example.test/login'], ['navigate', 'https://example.test/login']])
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
context 'with VIDEO=off and TRACE=off' do
|
|
929
|
+
let(:env) { { 'VIDEO' => 'off', 'TRACE' => 'off' } }
|
|
930
|
+
|
|
931
|
+
it 'passes no listener at all' do
|
|
932
|
+
expect(created_arguments.keys).to eq([:options])
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
it 'does not set web_socket_url' do
|
|
936
|
+
expect(options_for('chrome').web_socket_url).to be_nil
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
it 'sets web_socket_url only for chrome, edge and firefox and not with TRACE=off' do
|
|
941
|
+
expect(%w[chrome edge firefox safari].map { |browser| options_for(browser).web_socket_url }).to eq([true, true, true, nil])
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
# rubocop:enable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/ExpectOutput, RSpec/MultipleExpectations, RSpec/ExampleLength
|