ruby_raider 3.12.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/README.md +2 -0
  4. data/lib/commands/utility_commands.rb +16 -0
  5. data/lib/generators/automation/templates/page.tt +8 -3
  6. data/lib/generators/automation/templates/partials/initialize_selector.tt +3 -3
  7. data/lib/generators/automation/templates/partials/url_methods.tt +3 -2
  8. data/lib/generators/automation/templates/partials/visit_method.tt +5 -5
  9. data/lib/generators/cucumber/cucumber_generator.rb +1 -1
  10. data/lib/generators/cucumber/templates/env.tt +3 -3
  11. data/lib/generators/cucumber/templates/partials/selenium_env.tt +65 -1
  12. data/lib/generators/cucumber/templates/steps.tt +2 -2
  13. data/lib/generators/cucumber/templates/user_factory.tt +2 -0
  14. data/lib/generators/cucumber/templates/world.tt +5 -3
  15. data/lib/generators/generator.rb +25 -12
  16. data/lib/generators/helper_generator.rb +7 -0
  17. data/lib/generators/rspec/templates/accessibility_spec.tt +10 -10
  18. data/lib/generators/rspec/templates/spec.tt +2 -1
  19. data/lib/generators/templates/common/gemfile.tt +9 -9
  20. data/lib/generators/templates/common/partials/web_config.tt +7 -0
  21. data/lib/generators/templates/common/read_me.tt +45 -0
  22. data/lib/generators/templates/helpers/appium_helper.tt +17 -15
  23. data/lib/generators/templates/helpers/browser_helper.tt +1 -2
  24. data/lib/generators/templates/helpers/driver_helper.tt +22 -0
  25. data/lib/generators/templates/helpers/partials/appium_driver.tt +1 -1
  26. data/lib/generators/templates/helpers/partials/selenium_driver.tt +11 -1
  27. data/lib/generators/templates/helpers/raider_element.tt +4 -4
  28. data/lib/generators/templates/helpers/session_state.tt +1 -1
  29. data/lib/generators/templates/helpers/spec_helper.tt +20 -1
  30. data/lib/generators/templates/helpers/trace_helper.tt +1097 -0
  31. data/lib/generators/templates/helpers/wait_helper.tt +145 -4
  32. data/lib/ruby_raider.rb +9 -2
  33. data/lib/utilities/utilities.rb +7 -0
  34. data/lib/version +1 -1
  35. data/ruby_raider.gemspec +1 -0
  36. data/sig/commands/utility_commands.rbs +2 -0
  37. data/sig/generators/generator.rbs +6 -0
  38. data/sig/generators/helper_generator.rbs +1 -0
  39. data/sig/ruby_raider.rbs +1 -0
  40. data/sig/utilities/utilities.rbs +1 -0
  41. data/spec/commands/raider_commands_spec.rb +25 -1
  42. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +1 -1
  43. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +2 -4
  44. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +2 -4
  45. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +11 -2
  46. data/spec/generators/trace_helper_spec.rb +945 -0
  47. data/spec/integration/content/config_content_spec.rb +20 -0
  48. data/spec/integration/content/content_helper.rb +1 -5
  49. data/spec/integration/content/cucumber_reporter_content_spec.rb +51 -0
  50. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  51. data/spec/integration/content/helper_content_spec.rb +60 -1
  52. data/spec/integration/content/readme_content_spec.rb +46 -0
  53. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  54. data/spec/integration/content/rubocop_content_spec.rb +87 -0
  55. data/spec/integration/content/syntax_validation_spec.rb +31 -51
  56. data/spec/integration/content/test_content_spec.rb +5 -2
  57. data/spec/integration/end_to_end_spec.rb +415 -29
  58. data/spec/integration/frozen_string_literal_matcher.rb +13 -0
  59. data/spec/integration/generators/helpers_generator_spec.rb +26 -0
  60. data/spec/integration/generators/invoke_generators_spec.rb +60 -0
  61. data/spec/integration/helpers/wait_helper_spec.rb +183 -0
  62. data/spec/integration/scaffolding_e2e_spec.rb +1 -5
  63. data/spec/integration/trace_format_checker.rb +141 -0
  64. data/spec/integration/trace_page_server.rb +80 -0
  65. data/spec/utilities/trace_command_spec.rb +66 -0
  66. metadata +29 -2
@@ -0,0 +1,1097 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi/escape'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'selenium-webdriver'
7
+ require 'tmpdir'
8
+ require 'yaml'
9
+ require 'zlib'
10
+
11
+ # Records a trace of each test: one step per WaitHelper action and navigation, with
12
+ # a screenshot and the page HTML after each state-changing step. stop(keep: true)
13
+ # writes it as trace.zip in the raider-trace v1 format, which Raider Desktop and
14
+ # Hideout open. Any capture error prints one [trace] line and turns tracing off for
15
+ # the rest of the run; it never raises into a test. Something only left out (a
16
+ # screenshot, or network on a browser without BiDi) prints at most one notice. TRACE=off skips tracing and
17
+ # TRACE=on keeps the trace of passing tests too.
18
+ # The Recorder follows WaitHelper as its observer. Without helpers/wait_helper.rb a
19
+ # trace records only navigation and the failure. Navigation comes from TraceHelper.listener,
20
+ # and network and console from passive WebDriver BiDi events (chrome, edge and firefox).
21
+ module TraceHelper
22
+ DEFAULTS = { 'enabled' => true, 'max_steps' => 200, 'max_mb' => 20 }.freeze
23
+ PRODUCER = { name: 'ruby_raider', version: '<%= ruby_raider_version %>' }.freeze
24
+ # Only these get a screenshot and a snapshot; query actions cost almost nothing.
25
+ MEDIA_ACTIONS = %w[navigate click send_keys clear submit select_by hover drag_and_drop scroll_into_view].freeze
26
+ # Only these record what was typed or selected, as value (null when secret).
27
+ TYPED_ACTIONS = %w[send_keys select_by].freeze
28
+ SECRET_ENV_PREFIX = 'RAIDER_SECRET_'
29
+ # The driver helper sets web_socket_url for these while tracing is on.
30
+ BIDI_BROWSERS = %w[chrome edge firefox].freeze
31
+ # Step entries are named steps/NNNN.*, so a trace keeps at most 9999 steps.
32
+ MAX_STEPS_LIMIT = 9999
33
+
34
+ @disabled = false
35
+ @warned = false
36
+ @disable_warned = false
37
+ @active_recorder = nil
38
+
39
+ class << self
40
+ attr_accessor :active_recorder
41
+
42
+ def config
43
+ @config ||= DEFAULTS.merge(trace_config)
44
+ end
45
+
46
+ def enabled?
47
+ config['enabled'] != false && ENV['TRACE'] != 'off' && !@disabled
48
+ end
49
+
50
+ def keep_all?
51
+ ENV['TRACE'] == 'on'
52
+ end
53
+
54
+ # True while tracing is on for a browser that speaks WebDriver BiDi (Edge reports MicrosoftEdge).
55
+ def bidi?(browser)
56
+ name = browser.to_s.downcase.delete_prefix('microsoft')
57
+ enabled? && BIDI_BROWSERS.any? { |bidi_browser| name.start_with?(bidi_browser) }
58
+ end
59
+
60
+ # Passed to Selenium through the driver helper's fan-out listener when the driver is created.
61
+ def listener
62
+ return nil unless enabled?
63
+
64
+ @listener ||= Listener.new
65
+ end
66
+
67
+ # screenshot: ->(driver) { [bytes, ext] }, snapshot: ->(driver) { html }; nil takes the default.
68
+ def recorder_for(driver, screenshot: nil, snapshot: nil)
69
+ return null_recorder unless enabled?
70
+
71
+ recorder = Recorder.new(driver, screenshot: screenshot, snapshot: snapshot)
72
+ WaitHelper.observer = recorder if defined?(WaitHelper)
73
+ recorder
74
+ rescue StandardError => e
75
+ disable!("could not create a recorder: #{e.message}")
76
+ null_recorder
77
+ end
78
+
79
+ # Unhooks the recorder from WaitHelper and the navigation listener.
80
+ def release(recorder)
81
+ @active_recorder = nil if @active_recorder.equal?(recorder)
82
+ WaitHelper.observer = nil if defined?(WaitHelper) && WaitHelper.observer.equal?(recorder)
83
+ end
84
+
85
+ # The first reason tracing stopped always gets its own [trace] line, even after a notice.
86
+ def disable!(reason)
87
+ @disabled = true
88
+ release(@active_recorder)
89
+ detach_observer
90
+ return nil if @disable_warned
91
+
92
+ @disable_warned = true
93
+ say("#{reason}; tracing disabled for this run")
94
+ nil
95
+ end
96
+
97
+ # At most one notice per run, for something left out of a trace while tracing goes on.
98
+ def warn_once(message)
99
+ return nil if @warned
100
+
101
+ @warned = true
102
+ say(message)
103
+ nil
104
+ end
105
+
106
+ # Above 9999 is taken as 9999, silently; zero or less (or not a number) as the default.
107
+ def max_steps
108
+ value = config['max_steps'].to_i
109
+ value.positive? ? [value, MAX_STEPS_LIMIT].min : DEFAULTS['max_steps']
110
+ end
111
+
112
+ def max_bytes
113
+ value = config['max_mb'].to_f
114
+ ((value.positive? ? value : DEFAULTS['max_mb']) * 1024 * 1024).floor
115
+ end
116
+
117
+ # UTC ISO-8601 with milliseconds.
118
+ def timestamp(time = Time.now)
119
+ time.getutc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')
120
+ end
121
+
122
+ def monotonic_now
123
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ end
125
+
126
+ # The format's error shape, on steps and on test: { class:, message: } or nil.
127
+ def error_json(error)
128
+ error && { class: error.class.name || error.class.to_s, message: error.message }
129
+ end
130
+
131
+ # True when a typed value equals a RAIDER_SECRET_* environment value.
132
+ def secret_value?(value)
133
+ ENV.any? { |name, secret| name.start_with?(SECRET_ENV_PREFIX) && !secret.to_s.empty? && secret == value }
134
+ end
135
+
136
+ private
137
+
138
+ def null_recorder
139
+ detach_observer
140
+ NullRecorder.new
141
+ end
142
+
143
+ def detach_observer
144
+ WaitHelper.observer = nil if defined?(WaitHelper) && WaitHelper.observer.is_a?(Recorder)
145
+ end
146
+
147
+ def trace_config
148
+ trace = YAML.load_file('config/config.yml')['trace']
149
+ trace.is_a?(Hash) ? trace : {}
150
+ rescue StandardError
151
+ {}
152
+ end
153
+
154
+ # $stderr.puts, not Kernel#warn: warn is silenced under -W0.
155
+ # rubocop:disable Style/StderrPuts
156
+ def say(message)
157
+ $stderr.puts("[trace] #{message}")
158
+ end
159
+ # rubocop:enable Style/StderrPuts
160
+ end
161
+
162
+ # One step of the timeline. parent is the enclosing group Step; the index is given
163
+ # when the trace is written, after max_steps and eventually have dropped steps.
164
+ # element is the format's { tag, label, role }; secret is true, false, or nil while the field is unknown.
165
+ Step = Struct.new(:seq, :parent, :kind, :action, :locator, :url, :page_title, :started_at, :clock, :duration_ms,
166
+ :waited_ms, :retries, :status, :error, :source, :rect, :screenshot, :snapshot, :element,
167
+ :value, :secret, :typed_strings, :name, keyword_init: true) do
168
+ def finish(status, error)
169
+ self.status = status.to_s
170
+ self.error = error
171
+ self.duration_ms = ((TraceHelper.monotonic_now - clock) * 1000).round
172
+ end
173
+
174
+ def within?(group)
175
+ step = parent
176
+ step = step.parent until step.nil? || step.equal?(group)
177
+ !step.nil?
178
+ end
179
+
180
+ def as_json(index, parent_index, media)
181
+ json = { index: index, parent_index: parent_index, kind: kind, action: action, locator: locator }
182
+ json[:element] = element if element
183
+ json[:name] = name if name
184
+ json.update(typed) if TYPED_ACTIONS.include?(action)
185
+ json.update(url: url, page_title: page_title, started_at: started_at, duration_ms: duration_ms.to_i,
186
+ waited_ms: waited_ms.to_i, retries: retries.to_i, status: status, error: TraceHelper.error_json(error),
187
+ source: source, rect: rect, screenshot: media[:screenshots], snapshot: media[:snapshots])
188
+ end
189
+
190
+ # A field whose secrecy could not be read (it was never ready, or its script failed) is masked too.
191
+ def typed
192
+ masked = secret != false
193
+ { value: masked ? nil : value, secret: masked }
194
+ end
195
+ end
196
+
197
+ # Follows a test: WaitHelper reports its actions (the observer protocol) and the
198
+ # navigation listener calls navigated. Media goes to its own tmpdir until stop.
199
+ # BiDi events arrive on Selenium's websocket threads, so the event lists are locked.
200
+ class Recorder
201
+ SNAPSHOT = :page_source.to_proc
202
+
203
+ attr_reader :dir, :capabilities
204
+
205
+ # The default screenshot is a viewport JPEG through BiDi when it is attached, else a PNG.
206
+ def initialize(driver, screenshot: nil, snapshot: nil)
207
+ @driver = driver
208
+ @screenshot = screenshot || ->(page_driver) { Screenshot.take(page_driver, bidi: @bidi) }
209
+ @snapshot = snapshot || SNAPSHOT
210
+ @recording = false
211
+ @bidi = false
212
+ @lock = Mutex.new
213
+ end
214
+
215
+ def start
216
+ @dir = Dir.mktmpdir('raider-trace')
217
+ reset
218
+ @recording = true
219
+ TraceHelper.active_recorder = self
220
+ connect_bidi
221
+ nil
222
+ rescue StandardError => e
223
+ TraceHelper.disable!("tracing failed to start: #{e.message}")
224
+ discard
225
+ end
226
+
227
+ # Returns the step as the token WaitHelper hands back. args: send_keys' keys or select_by's how and what.
228
+ # The locator has no label: a page object's element method has returned before the action runs.
229
+ def action_started(action:, locator:, within:, source:, args: nil)
230
+ record('action') do
231
+ step = add(kind: 'action', action: action.to_s, locator: trace_locator(locator, within), source: source)
232
+ typed(step, Array(args)) if TYPED_ACTIONS.include?(step.action)
233
+ step
234
+ end
235
+ end
236
+
237
+ # The element is ready and the action is about to run: its rect, what it is, and whether it is secret.
238
+ # Query actions skip it, to stay cheap.
239
+ def action_ready(step, element:)
240
+ record('action') do
241
+ element_info(step, element) if element && MEDIA_ACTIONS.include?(step.action)
242
+ nil
243
+ end
244
+ end
245
+
246
+ def action_finished(step, status:, waited_ms:, retries:, error:, **)
247
+ record('action') do
248
+ step.finish(status, error)
249
+ step.waited_ms = waited_ms
250
+ step.retries = retries
251
+ remember_secret(step)
252
+ capture_step_media(step) if MEDIA_ACTIONS.include?(step.action)
253
+ nil
254
+ end
255
+ end
256
+
257
+ def group_started(action:, source:)
258
+ record('group') do
259
+ group = add(kind: 'group', action: action.to_s, locator: nil, source: source)
260
+ @groups.push(group)
261
+ group
262
+ end
263
+ end
264
+
265
+ # A new pass of eventually: only the last pass's steps are kept, and the
266
+ # group's error is the last one an earlier pass raised.
267
+ def group_retried(group, error: nil)
268
+ record('group') do
269
+ dropped, @steps = @steps.partition { |step| step.within?(group) }
270
+ dropped.each { |step| delete_media(step) }
271
+ group.error = error
272
+ nil
273
+ end
274
+ end
275
+
276
+ def group_finished(group, status:, retries:, error:)
277
+ record('group') do
278
+ @groups.reject! { |open| open.equal?(group) }
279
+ group.finish(status, error || group.error)
280
+ group.retries = retries
281
+ nil
282
+ end
283
+ end
284
+
285
+ # A Cucumber step is a group named by its keyword and text; the actions it runs become its children.
286
+ def step_started(name:, source:)
287
+ group = group_started(action: :cucumber_step, source: source)
288
+ group.name = name if group.is_a?(Step)
289
+ group
290
+ end
291
+
292
+ def step_finished(group, status:, error:)
293
+ group_finished(group, status: status, retries: 0, error: error) if group.is_a?(Step)
294
+ end
295
+
296
+ def navigated(url)
297
+ record('navigation') do
298
+ step = add(kind: 'navigate', action: 'navigate', locator: nil, source: navigation_source)
299
+ step.url = url
300
+ step.finish(:passed, nil)
301
+ capture_step_media(step)
302
+ nil
303
+ end
304
+ end
305
+
306
+ # hash: a network[] entry of the format; step_index is filled in when written.
307
+ def network_event(hash)
308
+ record('network') do
309
+ event(:network, hash)
310
+ nil
311
+ end
312
+ end
313
+
314
+ # A request whose response is still to come: network_completed finishes it by its BiDi
315
+ # request id. at: the event's epoch milliseconds, for duration_ms.
316
+ # Each BiDi event runs on its own thread, so a completion can come first; it is then kept
317
+ # until its start arrives.
318
+ def network_started(request, hash, at: nil)
319
+ record('network') do
320
+ entry = event(:network, hash)
321
+ early = @lock.synchronize do
322
+ @requests[request] = [entry, at]
323
+ @early.delete(request)
324
+ end
325
+ network_completed(request, *early) if early
326
+ nil
327
+ end
328
+ end
329
+
330
+ # Merges fields into the entry started for request; a redirect's next hop starts a new one.
331
+ def network_completed(request, fields, at = nil)
332
+ record('network') do
333
+ entry, started = @lock.synchronize do
334
+ @requests.delete(request).tap { |found| @early[request] = [fields, at] unless found }
335
+ end
336
+ complete(entry, fields, at, started) if entry
337
+ nil
338
+ end
339
+ end
340
+
341
+ # hash: a console[] entry of the format; step_index is filled in when written.
342
+ def console_event(hash)
343
+ record('console') do
344
+ event(:console, hash)
345
+ nil
346
+ end
347
+ end
348
+
349
+ # The screenshot and the sanitised page HTML after a step, and the page it ended on.
350
+ # One that fails is left out (null in the trace); the step is still kept. While an
351
+ # alert is open nothing is read: any page command would dismiss it.
352
+ def capture_step_media(step)
353
+ return nil if alert_open?
354
+
355
+ page = Page.capture(@driver, @snapshot)
356
+ read_page(step, page)
357
+ step.screenshot = media(step, 'screenshot') { @screenshot.call(@driver) }
358
+ step.snapshot = media(step, 'snapshot') do
359
+ raise page[:error] unless page[:html]
360
+
361
+ [Snapshot.sanitize(page[:html], css: page[:css], skipped: page[:skipped_sheets], secrets: @secrets), 'html']
362
+ end
363
+ nil
364
+ end
365
+
366
+ # test: { identifier:, full_name:, status:, attempt:, error: }. Returns the trace.zip path, or nil.
367
+ def stop(keep:, test:)
368
+ return nil unless @recording
369
+
370
+ @recording = false
371
+ TraceHelper.release(self)
372
+ return discard unless keep && TraceHelper.enabled?
373
+
374
+ failure(test[:error])
375
+ write(test)
376
+ rescue StandardError => e
377
+ TraceHelper.disable!("could not write the trace: #{e.message}")
378
+ discard
379
+ end
380
+
381
+ def cleanup
382
+ TraceHelper.release(self)
383
+ FileUtils.rm_rf(@dir) if @dir
384
+ nil
385
+ rescue StandardError => e
386
+ TraceHelper.disable!("could not remove the trace files: #{e.message}")
387
+ end
388
+
389
+ private
390
+
391
+ def reset
392
+ @steps = []
393
+ @groups = []
394
+ @lock.synchronize do
395
+ @events = []
396
+ @requests = {}
397
+ @early = {}
398
+ end
399
+ @bidi = false
400
+ @seq = 0
401
+ @secrets = []
402
+ @page = { url: nil, page_title: nil }
403
+ @truncated = { steps: 0, screenshots: 0, snapshots: 0, network: 0, console: 0 }
404
+ @capabilities = { network: false, console: false, screenshots: true, snapshots: true }
405
+ @meta = { browser: browser, viewport: viewport, device_pixel_ratio: pixel_ratio }
406
+ @started_at = TraceHelper.timestamp
407
+ end
408
+
409
+ # Network and console come over BiDi; without it the trace still has its steps and media.
410
+ def connect_bidi
411
+ name = @meta[:browser][:name].to_s
412
+ if TraceHelper.bidi?(name)
413
+ @bidi = Bidi.attach(@driver, self)
414
+ else
415
+ TraceHelper.warn_once("network and console not captured: #{name.empty? ? 'this browser' : name} has no WebDriver BiDi")
416
+ end
417
+ @capabilities[:network] = @capabilities[:console] = @bidi
418
+ end
419
+
420
+ # Runs a hook while recording. A hook that fails turns tracing off instead of
421
+ # raising into WaitHelper (which would warn a second time).
422
+ def record(what)
423
+ return nil unless @recording && TraceHelper.enabled?
424
+
425
+ yield
426
+ rescue StandardError => e
427
+ TraceHelper.disable!("#{what} capture failed: #{e.message}")
428
+ end
429
+
430
+ def add(kind:, action:, locator:, source:)
431
+ @seq += 1
432
+ step = Step.new(seq: @seq, parent: @groups.last, kind: kind, action: action, locator: locator, source: source,
433
+ url: @page[:url], page_title: @page[:page_title], started_at: TraceHelper.timestamp,
434
+ clock: TraceHelper.monotonic_now, waited_ms: 0, retries: 0)
435
+ @steps << step
436
+ drop_oldest_step while @steps.size > TraceHelper.max_steps
437
+ step
438
+ end
439
+
440
+ def drop_oldest_step
441
+ delete_media(@steps.shift)
442
+ @truncated[:steps] += 1
443
+ end
444
+
445
+ def complete(entry, fields, finished, started)
446
+ fields = fields.merge(duration_ms: (finished - started).round) if finished.is_a?(Numeric) && started.is_a?(Numeric)
447
+ @lock.synchronize { entry.update(fields.transform_keys(&:to_s)) }
448
+ end
449
+
450
+ # Returns the stored entry, which network_completed may still fill in.
451
+ def event(list, hash)
452
+ entry = hash.transform_keys(&:to_s)
453
+ @lock.synchronize { @events << [list, @seq, entry] }
454
+ entry
455
+ end
456
+
457
+ def trace_locator(locator, within)
458
+ return nil unless locator
459
+
460
+ traced = { strategy: locator[:strategy], selector: locator[:selector] }
461
+ traced[:within] = within unless within.nil? || within.empty?
462
+ traced
463
+ end
464
+
465
+ def navigation_source
466
+ WaitHelper::Observation.source if defined?(WaitHelper::Observation)
467
+ end
468
+
469
+ # The value as typed: strings as they are, keys such as :enter as {enter}; select_by's what.
470
+ def typed(step, args)
471
+ shown = step.action == 'select_by' ? args.last.to_s : args.flatten.map { |key| key_name(key) }.join
472
+ step.value = shown
473
+ step.typed_strings = [shown, *args.flatten.grep(String)].reject(&:empty?).uniq
474
+ step.secret = true if step.typed_strings.any? { |each_value| TraceHelper.secret_value?(each_value) }
475
+ end
476
+
477
+ # What a masked step typed, so this test's later snapshots blank it too (a show-password
478
+ # toggle makes the field type=text, but its value is still the secret).
479
+ def remember_secret(step)
480
+ return unless TYPED_ACTIONS.include?(step.action) && step.typed[:secret]
481
+
482
+ @secrets |= step.typed_strings.to_a
483
+ end
484
+
485
+ def key_name(key)
486
+ key.is_a?(Symbol) ? "{#{key}}" : key.to_s
487
+ end
488
+
489
+ def element_info(step, element)
490
+ info = Page.element_info(@driver, element)
491
+ return unless info
492
+
493
+ step.rect = info[:rect]
494
+ step.element = info[:element]
495
+ step.secret = info[:secret] || step.secret || false unless info[:secret].nil?
496
+ end
497
+
498
+ # VideoHelper's check, called rather than copied; without the video helper there is no check.
499
+ def alert_open?
500
+ return false unless defined?(VideoHelper) && VideoHelper.respond_to?(:alert_open?)
501
+
502
+ VideoHelper.alert_open?(@driver) == true
503
+ rescue StandardError
504
+ false
505
+ end
506
+
507
+ # A step whose page cannot be read keeps the URL and title it started with.
508
+ def read_page(step, page)
509
+ @page = { url: page[:url] || @page[:url], page_title: page[:page_title] || @page[:page_title] }
510
+ step.url = @page[:url]
511
+ step.page_title = @page[:page_title]
512
+ end
513
+
514
+ def media(step, kind)
515
+ bytes, ext = yield
516
+ path = File.join(@dir, format('%<seq>06d-%<kind>s.%<ext>s', seq: step.seq, kind: kind, ext: ext))
517
+ File.binwrite(path, bytes)
518
+ path
519
+ rescue StandardError => e
520
+ TraceHelper.warn_once("#{kind} not captured: #{e.message}")
521
+ nil
522
+ end
523
+
524
+ def delete_media(step)
525
+ [step.screenshot, step.snapshot].compact.each { |path| FileUtils.rm_f(path) }
526
+ end
527
+
528
+ # A test that raised outside every kept step gets a last failure step. Steps dropped by
529
+ # eventually or max_steps own nothing, so their error still gets a step.
530
+ def failure(error)
531
+ return if error.nil? || @steps.any? { |step| step.error.equal?(error) }
532
+
533
+ step = add(kind: 'failure', action: 'failure', locator: nil, source: nil)
534
+ step.finish(:failed, error)
535
+ capture_step_media(step)
536
+ end
537
+
538
+ def write(test)
539
+ @steps.each { |step| step.finish(:failed, nil) unless step.status }
540
+ positions = {}.compare_by_identity
541
+ @steps.each_with_index { |step, position| positions[step] = position }
542
+ caps = Caps.new(TraceHelper.max_bytes)
543
+ media, events = caps.fit(media_entries, event_entries, @steps.size) { |kept, listed| index(test, positions, kept, listed) }
544
+ caps.drops.each { |kind, count| @truncated[kind] += count }
545
+ path = File.join(@dir, 'trace.zip')
546
+ ZipWriter.write(path, [['trace.json', index(test, positions, media, events), true], *media.map(&:entry)])
547
+ end
548
+
549
+ # [Caps::Media] for every captured file, oldest step first.
550
+ def media_entries
551
+ @steps.each_with_index.flat_map do |step, position|
552
+ { screenshots: step.screenshot, snapshots: step.snapshot }.filter_map do |kind, file|
553
+ next unless file
554
+
555
+ name = format('steps/%<index>04d%<ext>s', index: position + 1, ext: File.extname(file))
556
+ Caps::Media.new(position, kind, ZipWriter.entry(name, File.binread(file), kind == :snapshots))
557
+ end
558
+ end
559
+ end
560
+
561
+ # [Caps::Event] in arrival order, each with the step that was running or had just run.
562
+ def event_entries
563
+ seqs = @steps.map(&:seq)
564
+ events = @lock.synchronize { @events.map { |list, seq, hash| [list, seq, hash.dup] } }
565
+ events.map do |list, seq, hash|
566
+ step_index = [seqs.bsearch_index { |each_seq| each_seq > seq } || seqs.size, 1].max
567
+ entry = { 'step_index' => step_index }.merge(hash.reject { |key, _| key == 'step_index' })
568
+ Caps::Event.new(list, entry, JSON.generate(entry).bytesize + 1)
569
+ end
570
+ end
571
+
572
+ def index(test, positions, media, events)
573
+ names = media.each_with_object(Hash.new { |hash, key| hash[key] = {} }) do |item, by_position|
574
+ by_position[item.position][item.kind] = item.entry.name
575
+ end
576
+ JSON.generate(document(test, steps_json(positions, names), events))
577
+ end
578
+
579
+ def steps_json(positions, names)
580
+ @steps.each_with_index.map do |step, position|
581
+ parent = step.parent && positions[step.parent]
582
+ step.as_json(position + 1, parent && (parent + 1), names.fetch(position, {}))
583
+ end
584
+ end
585
+
586
+ def document(test, steps, events)
587
+ { format: 'raider-trace', version: 1, producer: PRODUCER,
588
+ test: { identifier: test[:identifier], full_name: test[:full_name], status: test[:status].to_s, attempt: test[:attempt],
589
+ error: TraceHelper.error_json(test[:error]) },
590
+ browser: @meta[:browser], viewport: @meta[:viewport], device_pixel_ratio: @meta[:device_pixel_ratio],
591
+ started_at: @started_at, finished_at: TraceHelper.timestamp, capabilities: @capabilities, truncated: @truncated,
592
+ steps: steps, network: events.select { |item| item.list == :network }.map(&:entry),
593
+ console: events.select { |item| item.list == :console }.map(&:entry) }
594
+ end
595
+
596
+ # Selenium reports Edge as MicrosoftEdge; the trace names it edge, like config.yml.
597
+ def browser
598
+ caps = @driver.capabilities
599
+ name = caps.browser_name.to_s
600
+ { name: name.casecmp?('MicrosoftEdge') ? 'edge' : name, version: caps.browser_version.to_s }
601
+ rescue StandardError
602
+ { name: nil, version: nil }
603
+ end
604
+
605
+ def viewport
606
+ size = @driver.manage.window.size
607
+ { width: size.width, height: size.height }
608
+ rescue StandardError
609
+ { width: nil, height: nil }
610
+ end
611
+
612
+ def pixel_ratio
613
+ ratio = @driver.execute_script('return window.devicePixelRatio')
614
+ ratio.is_a?(Numeric) && ratio.positive? ? ratio : 1
615
+ rescue StandardError
616
+ 1
617
+ end
618
+
619
+ def discard
620
+ cleanup
621
+ nil
622
+ end
623
+ end
624
+
625
+ # Keeps the zip under max_mb. Drops, in order: the HTML of the oldest steps, then
626
+ # their screenshots, then the oldest network and console entries. The index and
627
+ # the last 10 steps' media are never dropped. drops counts what went, by kind.
628
+ class Caps
629
+ KEEP_LAST = 10
630
+ MEDIA_ORDER = %i[snapshots screenshots].freeze
631
+ Media = Struct.new(:position, :kind, :entry)
632
+ Event = Struct.new(:list, :entry, :bytes)
633
+
634
+ attr_reader :drops
635
+
636
+ def initialize(limit)
637
+ @limit = limit
638
+ @drops = Hash.new(0)
639
+ end
640
+
641
+ # Yields (media, events) for the trace.json they give; returns the [media, events] to keep.
642
+ def fit(media, events, step_count)
643
+ queue = drop_order(media, events, step_count)
644
+ loop do
645
+ index = yield(media, events)
646
+ excess = size(media, index) - @limit
647
+ return [media, events] if excess <= 0 || queue.empty?
648
+
649
+ ratio = deflated(index).bytesize.fdiv([index.bytesize, 1].max)
650
+ media, events = drop(queue, media, events, excess, ratio)
651
+ end
652
+ end
653
+
654
+ private
655
+
656
+ def drop_order(media, events, step_count)
657
+ droppable = media.select { |item| item.position < step_count - KEEP_LAST }
658
+ MEDIA_ORDER.flat_map { |kind| droppable.select { |item| item.kind == kind } } + events
659
+ end
660
+
661
+ # Drops from the front of the queue until the estimate says excess bytes went.
662
+ def drop(queue, media, events, excess, ratio)
663
+ gone = {}.compare_by_identity
664
+ while excess.positive? && !queue.empty?
665
+ item = queue.shift
666
+ gone[item] = true
667
+ @drops[item.is_a?(Media) ? item.kind : item.list] += 1
668
+ excess -= item.is_a?(Media) ? ZipWriter.footprint(item.entry) : item.bytes * ratio
669
+ end
670
+ [media.reject { |item| gone.key?(item) }, events.reject { |item| gone.key?(item) }]
671
+ end
672
+
673
+ def size(media, index)
674
+ ZipWriter::END_RECORD_BYTES + ZipWriter.footprint(ZipWriter::Entry.new('trace.json', deflated(index))) +
675
+ media.sum { |item| ZipWriter.footprint(item.entry) }
676
+ end
677
+
678
+ def deflated(bytes)
679
+ ZipWriter.entry('trace.json', bytes, true).data
680
+ end
681
+ end
682
+
683
+ # A minimal zip writer (no rubyzip in generated projects): stored or raw-deflated
684
+ # entries, a central directory and the end record. No directories, no zip64.
685
+ module ZipWriter
686
+ Entry = Struct.new(:name, :data, :crc, :raw_size, :compression)
687
+ STORED = 0
688
+ DEFLATED = 8
689
+ VERSION = 20
690
+ LOCAL_HEADER_BYTES = 30
691
+ CENTRAL_RECORD_BYTES = 46
692
+ END_RECORD_BYTES = 22
693
+
694
+ def self.entry(name, bytes, deflate)
695
+ bytes = bytes.to_s.b
696
+ data = deflate ? raw_deflate(bytes) : bytes
697
+ Entry.new(name, data, Zlib.crc32(bytes), bytes.bytesize, deflate ? DEFLATED : STORED)
698
+ end
699
+
700
+ # The bytes an entry takes in the zip: its local header, its data and its central record.
701
+ def self.footprint(entry)
702
+ LOCAL_HEADER_BYTES + CENTRAL_RECORD_BYTES + (2 * entry.name.bytesize) + entry.data.bytesize
703
+ end
704
+
705
+ # entries: [[name, bytes, deflate]] or Entry. Returns path.
706
+ def self.write(path, entries)
707
+ entries = entries.map { |each_entry| each_entry.is_a?(Entry) ? each_entry : entry(*each_entry) }
708
+ stamp = dos_time(Time.now)
709
+ File.open(path, 'wb') do |file|
710
+ central = entries.map do |each_entry|
711
+ offset = file.pos
712
+ file.write(header(each_entry, stamp), each_entry.data)
713
+ central_record(each_entry, stamp, offset)
714
+ end
715
+ start = file.pos
716
+ file.write(central.join)
717
+ file.write([0x06054b50, 0, 0, entries.size, entries.size, file.pos - start, start, 0].pack('VvvvvVVv'))
718
+ end
719
+ path
720
+ end
721
+
722
+ def self.raw_deflate(bytes)
723
+ deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
724
+ deflater.deflate(bytes, Zlib::FINISH)
725
+ ensure
726
+ deflater&.close
727
+ end
728
+
729
+ def self.header(entry, stamp)
730
+ [0x04034b50, VERSION, 0, entry.compression, *stamp, entry.crc, entry.data.bytesize, entry.raw_size,
731
+ entry.name.bytesize, 0].pack('VvvvvvVVVvv') + entry.name
732
+ end
733
+
734
+ def self.central_record(entry, stamp, offset)
735
+ [0x02014b50, VERSION, VERSION, 0, entry.compression, *stamp, entry.crc, entry.data.bytesize, entry.raw_size,
736
+ entry.name.bytesize, 0, 0, 0, 0, 0, offset].pack('VvvvvvvVVVvvvvvVV') + entry.name
737
+ end
738
+
739
+ # [time, date] in MS-DOS format.
740
+ def self.dos_time(time)
741
+ [(time.hour << 11) | (time.min << 5) | (time.sec / 2), ((time.year - 1980) << 9) | (time.month << 5) | time.day]
742
+ end
743
+ end
744
+
745
+ # Used when tracing is off.
746
+ class NullRecorder
747
+ def start
748
+ nil
749
+ end
750
+
751
+ def navigated(_url)
752
+ nil
753
+ end
754
+
755
+ def network_event(_hash)
756
+ nil
757
+ end
758
+
759
+ def network_started(_request, _hash, **)
760
+ nil
761
+ end
762
+
763
+ def network_completed(_request, _fields, _at = nil)
764
+ nil
765
+ end
766
+
767
+ def console_event(_hash)
768
+ nil
769
+ end
770
+
771
+ def stop(**)
772
+ nil
773
+ end
774
+
775
+ def cleanup
776
+ nil
777
+ end
778
+ end
779
+ end
780
+
781
+ # What a step reads from the page, and the snapshot made of its HTML. Kept apart from the
782
+ # recorder above. Every script is guarded: one that fails leaves its fields nil.
783
+ module TraceHelper
784
+ # Reads the page through the driver, one execute_script per call.
785
+ module Page
786
+ # The cssText of every enabled sheet in document order, @import and media kept; a
787
+ # sheet whose rules cannot be read (cross-origin) is skipped and counted. ES5 only.
788
+ STYLES = <<~'JS'
789
+ var skipped = 0;
790
+ function read(sheet) {
791
+ var rules = null, parts = [], media = sheet.media && sheet.media.mediaText;
792
+ try { rules = sheet.cssRules; } catch (e) { rules = null; }
793
+ if (!rules) { skipped += 1; return ''; }
794
+ for (var i = 0; i < rules.length; i += 1) {
795
+ parts.push(rules[i].styleSheet ? read(rules[i].styleSheet) : rules[i].cssText);
796
+ }
797
+ var text = parts.join('\n');
798
+ return media && media !== 'all' ? '@media ' + media + ' {\n' + text + '\n}' : text;
799
+ }
800
+ var css = [];
801
+ for (var i = 0; i < document.styleSheets.length; i += 1) {
802
+ if (!document.styleSheets[i].disabled) { css.push(read(document.styleSheets[i])); }
803
+ }
804
+ return { css: css.join('\n'), skipped: skipped, title: document.title };
805
+ JS
806
+ # The element's viewport rect and what a person would call it. ES5 only.
807
+ ELEMENT = <<~'JS'
808
+ var el = arguments[0], box = el.getBoundingClientRect(), tag = el.tagName.toLowerCase();
809
+ var type = (el.getAttribute('type') || '').toLowerCase();
810
+ var text = (el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 40);
811
+ var roles = { button: 'button', textarea: 'textbox', select: 'combobox' };
812
+ var role = el.getAttribute('role') || roles[tag] || null;
813
+ if (!role && tag === 'a' && el.hasAttribute('href')) { role = 'link'; }
814
+ if (!role && tag === 'input') {
815
+ if (type === 'checkbox') { role = 'checkbox'; }
816
+ else if (['button', 'submit', 'reset', 'image'].indexOf(type) >= 0) { role = 'button'; }
817
+ else if (['', 'text', 'email', 'password', 'search', 'tel', 'url'].indexOf(type) >= 0) { role = 'textbox'; }
818
+ }
819
+ var autocomplete = (el.getAttribute('autocomplete') || '').toLowerCase();
820
+ return { rect: { x: box.left, y: box.top, width: box.width, height: box.height }, tag: tag,
821
+ label: text || el.getAttribute('aria-label') || el.getAttribute('placeholder') || null, role: role,
822
+ secret: type === 'password' || autocomplete.indexOf('password') >= 0 };
823
+ JS
824
+
825
+ # { html:, css:, skipped_sheets:, page_title:, url: }, plus error: when the HTML could not be read.
826
+ # source reads the HTML (the recorder's snapshot lambda, page_source by default).
827
+ def self.capture(driver, source = :page_source.to_proc)
828
+ styles = script(driver, STYLES) || {}
829
+ page = { css: styles['css'], skipped_sheets: styles['skipped'].to_i,
830
+ page_title: styles['title'] || read { driver.title }, url: read { driver.current_url } }
831
+ page.merge(html: source.call(driver))
832
+ rescue StandardError => e
833
+ (page || {}).merge(html: nil, error: e)
834
+ end
835
+
836
+ # { rect:, element: { tag:, label:, role: }, secret: }, or nil when the script fails.
837
+ def self.element_info(driver, element)
838
+ info = script(driver, ELEMENT, element)
839
+ return unless info
840
+
841
+ { rect: info['rect'], element: { tag: info['tag'], label: info['label'], role: info['role'] }.compact,
842
+ secret: info['secret'] }
843
+ end
844
+
845
+ # The script's Hash, or nil when it raised or answered something else.
846
+ def self.script(driver, source, *args)
847
+ result = driver.execute_script(source, *args)
848
+ result.is_a?(Hash) ? result : nil
849
+ rescue StandardError
850
+ nil
851
+ end
852
+
853
+ def self.read
854
+ yield
855
+ rescue StandardError
856
+ nil
857
+ end
858
+ end
859
+
860
+ # Makes page HTML safe to keep and to open offline. Scans the HTML string (no parser in a
861
+ # generated project); viewers sanitise again, so the aim is that real pages come out right.
862
+ module Snapshot
863
+ SCRIPT = %r{<script\b[^>]*>.*?</script\s*>|</?script\b[^>]*>}mi.freeze
864
+ STYLE = %r{<style\b[^>]*>.*?</style\s*>}mi.freeze
865
+ # A start tag, its attributes allowed to hold a quoted '>'.
866
+ TAG = /<([a-zA-Z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/.freeze
867
+ ATTRIBUTE = %r{([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?}.freeze
868
+ URL_ATTRIBUTES = %w[href src action formaction xlink:href].freeze
869
+ SKIPPED_ATTRIBUTE = 'data-raider-skipped-sheets'
870
+
871
+ # Removes scripts, on* handlers, javascript: URLs, <base> and meta refresh; blanks the value of
872
+ # secret inputs. With css (Page.capture's), one <style> holding it replaces every stylesheet
873
+ # link and <style>. <html> gets data-raider-skipped-sheets: the sheets that could not be read.
874
+ # Never adds a <base>: a viewer must not fetch from the site under test.
875
+ # secrets: values the recorder masked in this test; a value attribute equal to one, or to a
876
+ # RAIDER_SECRET_* value, is blanked whatever the field's type is now.
877
+ def self.sanitize(html, css:, skipped: 0, secrets: [])
878
+ html = html.to_s.gsub(SCRIPT, '')
879
+ html = html.gsub(STYLE, '') if css
880
+ links = 0
881
+ html = html.gsub(TAG) do
882
+ tag = Regexp.last_match
883
+ attributes = tag[2].to_enum(:scan, ATTRIBUTE).map { Regexp.last_match }
884
+ next '' if removed?(tag[1].downcase, attributes) { links += 1 }
885
+
886
+ clean(tag[0], tag[1], attributes, secrets)
887
+ end
888
+ html = counted(html, css ? skipped : links)
889
+ css ? with_style(html, css) : html
890
+ end
891
+
892
+ # A <base>, a meta refresh or a stylesheet link (which yields, to be counted).
893
+ def self.removed?(tag, attributes)
894
+ return true if tag == 'base'
895
+ return value(attributes, 'http-equiv').casecmp?('refresh') if tag == 'meta'
896
+ return false unless tag == 'link' && value(attributes, 'rel').downcase.split.include?('stylesheet')
897
+
898
+ yield
899
+ true
900
+ end
901
+
902
+ def self.clean(original, name, attributes, secrets)
903
+ field = name.casecmp?('input') && secret?(attributes)
904
+ kept = attributes.reject { |attribute| unsafe?(attribute) }
905
+ parts = kept.map { |attribute| blank?(attribute, field, secrets) ? 'value=""' : attribute[0] }
906
+ return original if parts == attributes.map { |attribute| attribute[0] }
907
+
908
+ rebuilt(original, name, parts)
909
+ end
910
+
911
+ # A value attribute of a secret field, or one holding a secret value. The HTML has it
912
+ # entity-encoded (& as &amp;), so it is decoded before it is compared.
913
+ def self.blank?(attribute, field, secrets)
914
+ return false unless attribute[1].casecmp?('value')
915
+
916
+ value = attribute_value(attribute)
917
+ return false if value.empty?
918
+
919
+ value = CGI.unescapeHTML(value)
920
+ field || secrets.include?(value) || TraceHelper.secret_value?(value)
921
+ end
922
+
923
+ def self.rebuilt(original, name, parts)
924
+ slash = original.match?(%r{/\s*>\z}) ? ' /' : ''
925
+ "<#{[name, *parts].join(' ')}#{slash}>"
926
+ end
927
+
928
+ # Sets data-raider-skipped-sheets on <html>, once every stylesheet link has been counted.
929
+ def self.counted(html, skipped)
930
+ html.sub(/<(html)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/i) do
931
+ tag = Regexp.last_match
932
+ parts = tag[2].to_enum(:scan, ATTRIBUTE).map { Regexp.last_match }
933
+ .reject { |attribute| attribute[1].casecmp?(SKIPPED_ATTRIBUTE) }.map { |attribute| attribute[0] }
934
+ rebuilt(tag[0], tag[1], parts << %(#{SKIPPED_ATTRIBUTE}="#{skipped.to_i}"))
935
+ end
936
+ end
937
+
938
+ def self.unsafe?(attribute)
939
+ name = attribute[1].downcase
940
+ return true if name.start_with?('on')
941
+
942
+ URL_ATTRIBUTES.include?(name) && attribute_value(attribute).gsub(/[\x00-\x20]/, '').match?(/\Ajavascript:/i)
943
+ end
944
+
945
+ def self.secret?(attributes)
946
+ value(attributes, 'type').casecmp?('password') || value(attributes, 'autocomplete').downcase.include?('password')
947
+ end
948
+
949
+ def self.value(attributes, name)
950
+ attribute = attributes.find { |each_attribute| each_attribute[1].casecmp?(name) }
951
+ attribute ? attribute_value(attribute) : ''
952
+ end
953
+
954
+ def self.attribute_value(attribute)
955
+ attribute[2] || attribute[3] || attribute[4] || ''
956
+ end
957
+
958
+ # One <style> at the end of <head>, or after <html> when there is no head.
959
+ def self.with_style(html, css)
960
+ style = "<style>#{css.gsub(%r{</(style)}i) { "<\\/#{Regexp.last_match(1)}" }}</style>"
961
+ return html.sub(%r{</head\s*>}i) { "#{style}#{Regexp.last_match(0)}" } if html.match?(%r{</head\s*>}i)
962
+ return html.sub(/<html\b(?:[^>"']|"[^"]*"|'[^']*')*>/i) { "#{Regexp.last_match(0)}#{style}" } if html.match?(/<html\b/i)
963
+
964
+ style + html
965
+ end
966
+ end
967
+ end
968
+
969
+ # What comes from the browser beside WaitHelper: navigation from Selenium's event listener,
970
+ # network and console from WebDriver BiDi, and the screenshot.
971
+ module TraceHelper
972
+ # Selenium calls these after each navigation (through the driver helper's fan-out listener).
973
+ class Listener < Selenium::WebDriver::Support::AbstractEventListener
974
+ def after_navigate_to(url, _driver)
975
+ navigated(url)
976
+ end
977
+
978
+ # The URL is read from the page with the step's media.
979
+ def after_navigate_back(_driver)
980
+ navigated(nil)
981
+ end
982
+
983
+ def after_navigate_forward(_driver)
984
+ navigated(nil)
985
+ end
986
+
987
+ private
988
+
989
+ def navigated(url)
990
+ TraceHelper.active_recorder&.navigated(url)
991
+ nil
992
+ end
993
+ end
994
+
995
+ # Passive BiDi: one session.subscribe per driver, never network.addIntercept, which would
996
+ # pause every request. Events go to the recorder last attached to that driver. Only the
997
+ # method, URL, status, MIME type, timing, size and error text are kept: no headers, no bodies.
998
+ module Bidi
999
+ HANDLERS = { 'network.beforeRequestSent' => :request_sent, 'network.responseCompleted' => :response_completed,
1000
+ 'network.fetchError' => :fetch_error, 'log.entryAdded' => :log_entry }.freeze
1001
+ Link = Struct.new(:recorder)
1002
+ @links = ObjectSpace::WeakMap.new
1003
+
1004
+ # True when the driver's network and console events now reach recorder. False, with one
1005
+ # [trace] warning, when the driver has no BiDi or the subscription fails.
1006
+ def self.attach(driver, recorder)
1007
+ link = @links[driver] ||= subscribe(driver.bidi)
1008
+ link.recorder = recorder
1009
+ true
1010
+ rescue StandardError => e
1011
+ TraceHelper.warn_once("network and console not captured: BiDi subscribe failed: #{e.message}")
1012
+ false
1013
+ end
1014
+
1015
+ def self.subscribe(bidi)
1016
+ bidi.session.subscribe(HANDLERS.keys)
1017
+ link = Link.new
1018
+ HANDLERS.each { |event, handler| bidi.add_callback(event) { |params| deliver(link, handler, params) } }
1019
+ link
1020
+ end
1021
+
1022
+ # On a websocket thread, which swallows errors: a failure is reported here.
1023
+ def self.deliver(link, handler, params)
1024
+ recorder = link.recorder
1025
+ send(handler, recorder, params) if recorder
1026
+ rescue StandardError => e
1027
+ TraceHelper.disable!("network and console capture failed: #{e.message}")
1028
+ end
1029
+
1030
+ def self.request_sent(recorder, params)
1031
+ request = params['request'] || {}
1032
+ recorder.network_started(request['request'],
1033
+ { method: request['method'], url: request['url'], status: nil, mime_type: nil,
1034
+ started_at: time(params['timestamp']), duration_ms: nil, bytes: nil, error: nil },
1035
+ at: params['timestamp'])
1036
+ end
1037
+
1038
+ def self.response_completed(recorder, params)
1039
+ response = params['response'] || {}
1040
+ recorder.network_completed(params.dig('request', 'request'),
1041
+ { status: response['status'], mime_type: response['mimeType'],
1042
+ bytes: response['bytesReceived'] || response['encodedDataLength'] },
1043
+ finished_at(params))
1044
+ end
1045
+
1046
+ # Epoch ms the response ended. Chrome stamps every event of a request with its start, so the end comes
1047
+ # from request.timings: ms after timeOrigin in Chrome, absolute (timeOrigin 0) in Firefox.
1048
+ def self.finished_at(params)
1049
+ stamp = params['timestamp']
1050
+ origin, response_end = params.dig('request', 'timings')&.values_at('timeOrigin', 'responseEnd')
1051
+ return stamp unless origin.is_a?(Numeric) && response_end.is_a?(Numeric) && response_end.positive?
1052
+
1053
+ [stamp, origin + response_end].grep(Numeric).max
1054
+ end
1055
+
1056
+ def self.fetch_error(recorder, params)
1057
+ recorder.network_completed(params.dig('request', 'request'), { error: params['errorText'] }, params['timestamp'])
1058
+ end
1059
+
1060
+ def self.log_entry(recorder, params)
1061
+ recorder.console_event(level: params['level'], text: params['text'], source: location(params),
1062
+ at: time(params['timestamp']))
1063
+ end
1064
+
1065
+ # "url:line:column" of the innermost frame, 1-based; nil without a stack trace.
1066
+ def self.location(params)
1067
+ frame = Array(params.dig('stackTrace', 'callFrames')).first
1068
+ frame && "#{frame['url']}:#{frame['lineNumber'].to_i + 1}:#{frame['columnNumber'].to_i + 1}"
1069
+ end
1070
+
1071
+ # BiDi timestamps are epoch milliseconds.
1072
+ def self.time(milliseconds)
1073
+ return TraceHelper.timestamp unless milliseconds.is_a?(Numeric)
1074
+
1075
+ TraceHelper.timestamp(Time.at(Rational(milliseconds.to_i, 1000)))
1076
+ end
1077
+ end
1078
+
1079
+ # The viewport after a step: a JPEG through BiDi, else (or when BiDi fails for that shot) a PNG.
1080
+ module Screenshot
1081
+ JPEG = { type: 'image/jpeg', quality: 0.7 }.freeze
1082
+
1083
+ # [bytes, ext]
1084
+ def self.take(driver, bidi:)
1085
+ (bidi && jpeg(driver)) || [driver.screenshot_as(:png), 'png']
1086
+ end
1087
+
1088
+ # In Chrome and Firefox BiDi the window handle is the browsing context id.
1089
+ def self.jpeg(driver)
1090
+ params = { context: driver.window_handle, origin: 'viewport', format: JPEG }
1091
+ result = driver.bidi.send_cmd('browsingContext.captureScreenshot', **params)
1092
+ [result.fetch('data').unpack1('m'), 'jpg']
1093
+ rescue StandardError
1094
+ nil
1095
+ end
1096
+ end
1097
+ end