ruby_raider 3.12.1 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/commands/utility_commands.rb +16 -0
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +65 -1
- data/lib/generators/generator.rb +10 -1
- data/lib/generators/helper_generator.rb +7 -0
- data/lib/generators/templates/common/gemfile.tt +1 -1
- data/lib/generators/templates/common/partials/web_config.tt +7 -0
- data/lib/generators/templates/common/read_me.tt +45 -0
- data/lib/generators/templates/helpers/driver_helper.tt +22 -0
- data/lib/generators/templates/helpers/partials/selenium_driver.tt +11 -1
- data/lib/generators/templates/helpers/raider_element.tt +4 -4
- data/lib/generators/templates/helpers/spec_helper.tt +19 -0
- data/lib/generators/templates/helpers/trace_helper.tt +1097 -0
- data/lib/generators/templates/helpers/wait_helper.tt +145 -4
- data/lib/utilities/utilities.rb +7 -0
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/commands/utility_commands.rbs +2 -0
- data/sig/generators/generator.rbs +4 -0
- data/sig/generators/helper_generator.rbs +1 -0
- data/sig/utilities/utilities.rbs +1 -0
- data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +11 -2
- data/spec/generators/trace_helper_spec.rb +945 -0
- data/spec/integration/content/config_content_spec.rb +20 -0
- data/spec/integration/content/cucumber_reporter_content_spec.rb +51 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/content/helper_content_spec.rb +60 -1
- data/spec/integration/content/readme_content_spec.rb +46 -0
- data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +392 -29
- data/spec/integration/generators/helpers_generator_spec.rb +26 -0
- data/spec/integration/helpers/wait_helper_spec.rb +183 -0
- data/spec/integration/trace_format_checker.rb +141 -0
- data/spec/integration/trace_page_server.rb +80 -0
- data/spec/utilities/trace_command_spec.rb +66 -0
- metadata +27 -2
|
@@ -0,0 +1,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 &), 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
|