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
@@ -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&amp;b&quot;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&amp;b&quot;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