ferrum 0.11 → 0.13

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +174 -30
  4. data/lib/ferrum/browser/binary.rb +46 -0
  5. data/lib/ferrum/browser/client.rb +17 -16
  6. data/lib/ferrum/browser/command.rb +10 -12
  7. data/lib/ferrum/browser/options/base.rb +2 -11
  8. data/lib/ferrum/browser/options/chrome.rb +29 -18
  9. data/lib/ferrum/browser/options/firefox.rb +13 -9
  10. data/lib/ferrum/browser/options.rb +84 -0
  11. data/lib/ferrum/browser/process.rb +45 -40
  12. data/lib/ferrum/browser/subscriber.rb +1 -3
  13. data/lib/ferrum/browser/version_info.rb +71 -0
  14. data/lib/ferrum/browser/web_socket.rb +9 -12
  15. data/lib/ferrum/browser/xvfb.rb +4 -8
  16. data/lib/ferrum/browser.rb +193 -47
  17. data/lib/ferrum/context.rb +9 -4
  18. data/lib/ferrum/contexts.rb +12 -10
  19. data/lib/ferrum/cookies/cookie.rb +126 -0
  20. data/lib/ferrum/cookies.rb +93 -55
  21. data/lib/ferrum/dialog.rb +30 -0
  22. data/lib/ferrum/errors.rb +115 -0
  23. data/lib/ferrum/frame/dom.rb +177 -0
  24. data/lib/ferrum/frame/runtime.rb +58 -75
  25. data/lib/ferrum/frame.rb +118 -23
  26. data/lib/ferrum/headers.rb +30 -2
  27. data/lib/ferrum/keyboard.rb +56 -13
  28. data/lib/ferrum/mouse.rb +92 -7
  29. data/lib/ferrum/network/auth_request.rb +7 -2
  30. data/lib/ferrum/network/exchange.rb +97 -12
  31. data/lib/ferrum/network/intercepted_request.rb +10 -8
  32. data/lib/ferrum/network/request.rb +69 -0
  33. data/lib/ferrum/network/response.rb +85 -3
  34. data/lib/ferrum/network.rb +285 -36
  35. data/lib/ferrum/node.rb +69 -23
  36. data/lib/ferrum/page/animation.rb +16 -1
  37. data/lib/ferrum/page/frames.rb +111 -30
  38. data/lib/ferrum/page/screenshot.rb +142 -65
  39. data/lib/ferrum/page/stream.rb +38 -0
  40. data/lib/ferrum/page/tracing.rb +97 -0
  41. data/lib/ferrum/page.rb +224 -60
  42. data/lib/ferrum/proxy.rb +147 -0
  43. data/lib/ferrum/{rbga.rb → rgba.rb} +4 -2
  44. data/lib/ferrum/target.rb +7 -4
  45. data/lib/ferrum/utils/attempt.rb +20 -0
  46. data/lib/ferrum/utils/elapsed_time.rb +27 -0
  47. data/lib/ferrum/utils/platform.rb +28 -0
  48. data/lib/ferrum/version.rb +1 -1
  49. data/lib/ferrum.rb +4 -146
  50. metadata +63 -51
@@ -5,41 +5,124 @@ require "ferrum/frame"
5
5
  module Ferrum
6
6
  class Page
7
7
  module Frames
8
+ # The page's main frame, the top of the tree and the parent of all frames.
9
+ #
10
+ # @return [Frame]
8
11
  attr_reader :main_frame
9
12
 
13
+ #
14
+ # Returns all the frames current page have.
15
+ #
16
+ # @return [Array<Frame>]
17
+ #
18
+ # @example
19
+ # browser.go_to("https://www.w3schools.com/tags/tag_frame.asp")
20
+ # browser.frames # =>
21
+ # # [
22
+ # # #<Ferrum::Frame
23
+ # # @id="C6D104CE454A025FBCF22B98DE612B12"
24
+ # # @parent_id=nil @name=nil @state=:stopped_loading @execution_id=1>,
25
+ # # #<Ferrum::Frame
26
+ # # @id="C09C4E4404314AAEAE85928EAC109A93"
27
+ # # @parent_id="C6D104CE454A025FBCF22B98DE612B12" @state=:stopped_loading @execution_id=2>,
28
+ # # #<Ferrum::Frame
29
+ # # @id="2E9C7F476ED09D87A42F2FEE3C6FBC3C"
30
+ # # @parent_id="C6D104CE454A025FBCF22B98DE612B12" @state=:stopped_loading @execution_id=3>,
31
+ # # ...
32
+ # # ]
33
+ #
10
34
  def frames
11
35
  @frames.values
12
36
  end
13
37
 
14
- def frame_by(id: nil, name: nil)
38
+ #
39
+ # Find frame by given options.
40
+ #
41
+ # @param [String] id
42
+ # Unique frame's id that browser provides.
43
+ #
44
+ # @param [String] name
45
+ # Frame's name if there's one.
46
+ #
47
+ # @param [String] execution_id
48
+ # Frame's context execution id.
49
+ #
50
+ # @return [Frame, nil]
51
+ # The matching frame.
52
+ #
53
+ # @example
54
+ # browser.frame_by(id: "C6D104CE454A025FBCF22B98DE612B12")
55
+ #
56
+ def frame_by(id: nil, name: nil, execution_id: nil)
15
57
  if id
16
58
  @frames[id]
17
59
  elsif name
18
60
  frames.find { |f| f.name == name }
61
+ elsif execution_id
62
+ frames.find { |f| f.execution_id == execution_id }
19
63
  else
20
64
  raise ArgumentError
21
65
  end
22
66
  end
23
67
 
24
68
  def frames_subscribe
69
+ subscribe_frame_attached
70
+ subscribe_frame_detached
71
+ subscribe_frame_started_loading
72
+ subscribe_frame_navigated
73
+ subscribe_frame_stopped_loading
74
+
75
+ subscribe_navigated_within_document
76
+
77
+ subscribe_request_will_be_sent
78
+
79
+ subscribe_execution_context_created
80
+ subscribe_execution_context_destroyed
81
+ subscribe_execution_contexts_cleared
82
+ end
83
+
84
+ private
85
+
86
+ def subscribe_frame_attached
25
87
  on("Page.frameAttached") do |params|
26
88
  parent_frame_id, frame_id = params.values_at("parentFrameId", "frameId")
27
- @frames[frame_id] = Frame.new(frame_id, self, parent_frame_id)
89
+ @frames.put_if_absent(frame_id, Frame.new(frame_id, self, parent_frame_id))
28
90
  end
91
+ end
92
+
93
+ def subscribe_frame_detached
94
+ on("Page.frameDetached") do |params|
95
+ frame = @frames[params["frameId"]]
96
+
97
+ if frame&.main?
98
+ frame.execution_id = nil
99
+ else
100
+ @frames.delete(params["frameId"])
101
+ end
102
+ end
103
+ end
29
104
 
105
+ def subscribe_frame_started_loading
30
106
  on("Page.frameStartedLoading") do |params|
31
107
  frame = @frames[params["frameId"]]
32
- frame.state = :started_loading
108
+ frame.state = :started_loading if frame
33
109
  @event.reset
34
110
  end
111
+ end
35
112
 
113
+ def subscribe_frame_navigated
36
114
  on("Page.frameNavigated") do |params|
37
115
  frame_id, name = params["frame"]&.values_at("id", "name")
38
116
  frame = @frames[frame_id]
39
- frame.state = :navigated
40
- frame.name = name unless name.to_s.empty?
117
+
118
+ if frame
119
+ frame.state = :navigated
120
+ frame.name = name
121
+ end
41
122
  end
123
+ end
42
124
 
125
+ def subscribe_frame_stopped_loading
43
126
  on("Page.frameStoppedLoading") do |params|
44
127
  # `DOM.performSearch` doesn't work without getting #document node first.
45
128
  # It returns node with nodeId 1 and nodeType 9 from which descend the
@@ -47,7 +130,7 @@ module Ferrum
47
130
  # node will change the id and all subsequent nodes have to change id too.
48
131
  if @main_frame.id == params["frameId"]
49
132
  @event.set if idling?
50
- get_document_id
133
+ document_node_id
51
134
  end
52
135
 
53
136
  frame = @frames[params["frameId"]]
@@ -55,60 +138,58 @@ module Ferrum
55
138
 
56
139
  @event.set if idling?
57
140
  end
141
+ end
58
142
 
143
+ def subscribe_navigated_within_document
59
144
  on("Page.navigatedWithinDocument") do
60
145
  @event.set if idling?
61
146
  end
147
+ end
62
148
 
149
+ def subscribe_request_will_be_sent
63
150
  on("Network.requestWillBeSent") do |params|
64
- if params["frameId"] == @main_frame.id
65
- # Possible types:
66
- # Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR,
67
- # Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping,
68
- # CSPViolationReport, Other
69
- @event.reset if params["type"] == "Document"
70
- end
151
+ # Possible types:
152
+ # Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR,
153
+ # Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping,
154
+ # CSPViolationReport, Other
155
+ @event.reset if params["frameId"] == @main_frame.id && params["type"] == "Document"
71
156
  end
157
+ end
72
158
 
159
+ def subscribe_execution_context_created
73
160
  on("Runtime.executionContextCreated") do |params|
74
- setting_up_main_frame = false
75
161
  context_id = params.dig("context", "id")
76
162
  frame_id = params.dig("context", "auxData", "frameId")
77
163
 
78
164
  unless @main_frame.id
79
165
  root_frame = command("Page.getFrameTree").dig("frameTree", "frame", "id")
80
166
  if frame_id == root_frame
81
- setting_up_main_frame = true
82
167
  @main_frame.id = frame_id
83
- @frames[frame_id] = @main_frame
168
+ @frames.put_if_absent(frame_id, @main_frame)
84
169
  end
85
170
  end
86
171
 
87
- frame = @frames[frame_id] || Frame.new(frame_id, self)
88
- frame.set_execution_id(context_id)
89
-
90
- # Set event because `execution_id` might raise NoExecutionContextError
91
- @event.set if setting_up_main_frame
92
-
93
- @frames[frame_id] ||= frame
172
+ frame = @frames.fetch_or_store(frame_id, Frame.new(frame_id, self))
173
+ frame.execution_id = context_id
94
174
  end
175
+ end
95
176
 
177
+ def subscribe_execution_context_destroyed
96
178
  on("Runtime.executionContextDestroyed") do |params|
97
179
  execution_id = params["executionContextId"]
98
- frame = frames.find { |f| f.execution_id?(execution_id) }
99
- frame.reset_execution_id
180
+ frame = frame_by(execution_id: execution_id)
181
+ frame&.execution_id = nil
100
182
  end
183
+ end
101
184
 
185
+ def subscribe_execution_contexts_cleared
102
186
  on("Runtime.executionContextsCleared") do
103
- @frames.delete_if { |_, f| !f.main? }
104
- @main_frame.reset_execution_id
187
+ @frames.each_value { |f| f.execution_id = nil }
105
188
  end
106
189
  end
107
190
 
108
- private
109
-
110
191
  def idling?
111
- @frames.all? { |_, f| f.state == :stopped_loading }
192
+ @frames.values.all? { |f| f.state == :stopped_loading }
112
193
  end
113
194
  end
114
195
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ferrum/rbga"
3
+ require "ferrum/rgba"
4
4
 
5
5
  module Ferrum
6
6
  class Page
@@ -13,21 +13,64 @@ module Ferrum
13
13
  }.freeze
14
14
 
15
15
  PAPER_FORMATS = {
16
- letter: { width: 8.50, height: 11.00 },
17
- legal: { width: 8.50, height: 14.00 },
18
- tabloid: { width: 11.00, height: 17.00 },
19
- ledger: { width: 17.00, height: 11.00 },
20
- A0: { width: 33.10, height: 46.80 },
21
- A1: { width: 23.40, height: 33.10 },
22
- A2: { width: 16.54, height: 23.40 },
23
- A3: { width: 11.70, height: 16.54 },
24
- A4: { width: 8.27, height: 11.70 },
25
- A5: { width: 5.83, height: 8.27 },
26
- A6: { width: 4.13, height: 5.83 },
16
+ letter: { width: 8.50, height: 11.00 },
17
+ legal: { width: 8.50, height: 14.00 },
18
+ tabloid: { width: 11.00, height: 17.00 },
19
+ ledger: { width: 17.00, height: 11.00 },
20
+ A0: { width: 33.10, height: 46.80 },
21
+ A1: { width: 23.40, height: 33.10 },
22
+ A2: { width: 16.54, height: 23.40 },
23
+ A3: { width: 11.70, height: 16.54 },
24
+ A4: { width: 8.27, height: 11.70 },
25
+ A5: { width: 5.83, height: 8.27 },
26
+ A6: { width: 4.13, height: 5.83 }
27
27
  }.freeze
28
28
 
29
- STREAM_CHUNK = 128 * 1024
30
-
29
+ #
30
+ # Saves screenshot on a disk or returns it as base64.
31
+ #
32
+ # @param [Hash{Symbol => Object}] opts
33
+ #
34
+ # @option opts [String] :path
35
+ # The path to save a screenshot on the disk. `:encoding` will be set to
36
+ # `:binary` automatically.
37
+ #
38
+ # @option opts [:base64, :binary] :encoding
39
+ # The encoding the image should be returned in.
40
+ #
41
+ # @option opts ["jpeg", "png"] :format
42
+ # The format the image should be returned in.
43
+ #
44
+ # @option opts [Integer] :quality
45
+ # The image quality. **Note:** 0-100 works for jpeg only.
46
+ #
47
+ # @option opts [Boolean] :full
48
+ # Whether you need full page screenshot or a viewport.
49
+ #
50
+ # @option opts [String] :selector
51
+ # CSS selector for the given element.
52
+ #
53
+ # @option opts [Float] :scale
54
+ # Zoom in/out.
55
+ #
56
+ # @option opts [Ferrum::RGBA] :background_color
57
+ # Sets the background color.
58
+ #
59
+ # @example
60
+ # browser.go_to("https://google.com/")
61
+ #
62
+ # @example Save on the disk in PNG:
63
+ # browser.screenshot(path: "google.png") # => 134660
64
+ #
65
+ # @example Save on the disk in JPG:
66
+ # browser.screenshot(path: "google.jpg") # => 30902
67
+ #
68
+ # @example Save to Base64 the whole page not only viewport and reduce quality:
69
+ # browser.screenshot(full: true, quality: 60) # "iVBORw0KGgoAAAANS...
70
+ #
71
+ # @example Save with specific background color:
72
+ # browser.screenshot(background_color: Ferrum::RGBA.new(0, 0, 0, 0.0))
73
+ #
31
74
  def screenshot(**opts)
32
75
  path, encoding = common_options(**opts)
33
76
  options = screenshot_options(path, **opts)
@@ -38,21 +81,63 @@ module Ferrum
38
81
  save_file(path, bin)
39
82
  end
40
83
 
84
+ #
85
+ # Saves PDF on a disk or returns it as Base64.
86
+ #
87
+ # @param [Hash{Symbol => Object}] opts
88
+ #
89
+ # @option opts [String] :path
90
+ # The path to save a screenshot on the disk. `:encoding` will be set to
91
+ # `:binary` automatically.
92
+ #
93
+ # @option opts [:base64, :binary] :encoding
94
+ # The encoding the image should be returned in.
95
+ #
96
+ # @option opts [Boolean] :landscape (false)
97
+ # Page orientation.
98
+ #
99
+ # @option opts [Float] :scale
100
+ # Zoom in/out.
101
+ #
102
+ # @option opts [:letter, :legal, :tabloid, :ledger, :A0, :A1, :A2, :A3, :A4, :A5, :A6] :format
103
+ # The standard paper size.
104
+ #
105
+ # @option opts [Float] :paper_width
106
+ # Sets the paper's width.
107
+ #
108
+ # @option opts [Float] :paper_height
109
+ # Sets the paper's height.
110
+ #
111
+ # @note
112
+ # See other [native options](https://chromedevtools.github.io/devtools-protocol/tot/Page#method-printToPDF) you
113
+ # can pass.
114
+ #
115
+ # @example
116
+ # browser.go_to("https://google.com/")
117
+ # # Save to disk as a PDF
118
+ # browser.pdf(path: "google.pdf", paper_width: 1.0, paper_height: 1.0) # => true
119
+ #
41
120
  def pdf(**opts)
42
121
  path, encoding = common_options(**opts)
43
122
  options = pdf_options(**opts).merge(transferMode: "ReturnAsStream")
44
123
  handle = command("Page.printToPDF", **options).fetch("stream")
45
-
46
- if path
47
- stream_to_file(handle, path: path)
48
- else
49
- stream_to_memory(handle, encoding: encoding)
50
- end
124
+ stream_to(path: path, encoding: encoding, handle: handle)
51
125
  end
52
126
 
127
+ #
128
+ # Saves MHTML on a disk or returns it as a string.
129
+ #
130
+ # @param [String, nil] path
131
+ # The path to save a file on the disk.
132
+ #
133
+ # @example
134
+ # browser.go_to("https://google.com/")
135
+ # browser.mhtml(path: "google.mhtml") # => 87742
136
+ #
53
137
  def mhtml(path: nil)
54
138
  data = command("Page.captureSnapshot", format: :mhtml).fetch("data")
55
139
  return data if path.nil?
140
+
56
141
  save_file(path, data)
57
142
  end
58
143
 
@@ -73,30 +158,8 @@ module Ferrum
73
158
 
74
159
  def save_file(path, data)
75
160
  return data unless path
76
- File.open(path.to_s, "wb") { |f| f.write(data) }
77
- end
78
161
 
79
- def stream_to_file(handle, path:)
80
- File.open(path, "wb") { |f| stream_to(handle, f) }
81
- true
82
- end
83
-
84
- def stream_to_memory(handle, encoding:)
85
- data = String.new("") # Mutable string has << and compatible to File
86
- stream_to(handle, data)
87
- encoding == :base64 ? Base64.encode64(data) : data
88
- end
89
-
90
- def stream_to(handle, output)
91
- loop do
92
- result = command("IO.read", handle: handle, size: STREAM_CHUNK)
93
-
94
- data_chunk = result["data"]
95
- data_chunk = Base64.decode64(data_chunk) if result["base64Encoded"]
96
- output << data_chunk
97
-
98
- break if result["eof"]
99
- end
162
+ File.binwrite(path.to_s, data)
100
163
  end
101
164
 
102
165
  def common_options(encoding: :base64, path: nil, **_)
@@ -119,44 +182,57 @@ module Ferrum
119
182
  paper_height: dimension[:height])
120
183
  end
121
184
 
122
- options.map { |k, v| [to_camel_case(k), v] }.to_h
185
+ options.transform_keys { |k| to_camel_case(k) }
123
186
  end
124
187
 
125
- def screenshot_options(path = nil, format: nil, scale: 1.0, **opts)
126
- options = {}
188
+ def screenshot_options(path = nil, format: nil, scale: 1.0, **options)
189
+ screenshot_options = {}
190
+
191
+ format, quality = format_options(format, path, options[:quality])
192
+ screenshot_options.merge!(quality: quality) if quality
193
+ screenshot_options.merge!(format: format)
194
+
195
+ clip = area_options(options[:full], options[:selector], scale)
196
+ screenshot_options.merge!(clip: clip) if clip
197
+
198
+ screenshot_options
199
+ end
127
200
 
201
+ def format_options(format, path, quality)
128
202
  format ||= path ? File.extname(path).delete(".") : "png"
129
203
  format = "jpeg" if format == "jpg"
130
204
  raise "Not supported options `:format` #{format}. jpeg | png" if format !~ /jpeg|png/i
131
- options.merge!(format: format)
132
205
 
133
- options.merge!(quality: opts[:quality] ? opts[:quality] : 75) if format == "jpeg"
206
+ quality ||= 75 if format == "jpeg"
134
207
 
135
- if !!opts[:full] && opts[:selector]
136
- warn "Ignoring :selector in #screenshot since full: true was given at #{caller(1..1).first}"
137
- end
208
+ [format, quality]
209
+ end
138
210
 
139
- if !!opts[:full]
140
- width, height = document_size
141
- options.merge!(clip: { x: 0, y: 0, width: width, height: height, scale: scale }) if width > 0 && height > 0
142
- elsif opts[:selector]
143
- options.merge!(clip: get_bounding_rect(opts[:selector]).merge(scale: scale))
144
- end
211
+ def area_options(full, selector, scale)
212
+ message = "Ignoring :selector in #screenshot since full: true was given at #{caller(1..1).first}"
213
+ warn(message) if full && selector
145
214
 
146
- if scale != 1.0
147
- if !options[:clip]
215
+ clip = if full
216
+ width, height = document_size
217
+ { x: 0, y: 0, width: width, height: height, scale: scale } if width.positive? && height.positive?
218
+ elsif selector
219
+ bounding_rect(selector).merge(scale: scale)
220
+ end
221
+
222
+ if scale != 1
223
+ unless clip
148
224
  width, height = viewport_size
149
- options[:clip] = { x: 0, y: 0, width: width, height: height }
225
+ clip = { x: 0, y: 0, width: width, height: height }
150
226
  end
151
227
 
152
- options[:clip].merge!(scale: scale)
228
+ clip.merge!(scale: scale)
153
229
  end
154
230
 
155
- options
231
+ clip
156
232
  end
157
233
 
158
- def get_bounding_rect(selector)
159
- rect = evaluate_async(%Q(
234
+ def bounding_rect(selector)
235
+ rect = evaluate_async(%(
160
236
  const rect = document
161
237
  .querySelector('#{selector}')
162
238
  .getBoundingClientRect();
@@ -169,7 +245,8 @@ module Ferrum
169
245
 
170
246
  def to_camel_case(option)
171
247
  return :preferCSSPageSize if option == :prefer_css_page_size
172
- option.to_s.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.to_sym
248
+
249
+ option.to_s.gsub(%r{(?:_|(/))([a-z\d]*)}) { "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}" }.to_sym
173
250
  end
174
251
 
175
252
  def capture_screenshot(options, full, background_color)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ module Stream
6
+ STREAM_CHUNK = 128 * 1024
7
+
8
+ def stream_to(path:, encoding:, handle:)
9
+ if path.nil?
10
+ stream_to_memory(encoding: encoding, handle: handle)
11
+ else
12
+ stream_to_file(path: path, handle: handle)
13
+ end
14
+ end
15
+
16
+ def stream_to_file(path:, handle:)
17
+ File.open(path, "wb") { |f| stream(output: f, handle: handle) }
18
+ true
19
+ end
20
+
21
+ def stream_to_memory(encoding:, handle:)
22
+ data = String.new # Mutable string has << and compatible to File
23
+ stream(output: data, handle: handle)
24
+ encoding == :base64 ? Base64.encode64(data) : data
25
+ end
26
+
27
+ def stream(output:, handle:)
28
+ loop do
29
+ result = command("IO.read", handle: handle, size: STREAM_CHUNK)
30
+ chunk = result.fetch("data")
31
+ chunk = Base64.decode64(chunk) if result["base64Encoded"]
32
+ output << chunk
33
+ break if result["eof"]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ class Tracing
6
+ EXCLUDED_CATEGORIES = %w[*].freeze
7
+ SCREENSHOT_CATEGORIES = %w[disabled-by-default-devtools.screenshot].freeze
8
+ INCLUDED_CATEGORIES = %w[devtools.timeline v8.execute disabled-by-default-devtools.timeline
9
+ disabled-by-default-devtools.timeline.frame toplevel blink.console
10
+ blink.user_timing latencyInfo disabled-by-default-devtools.timeline.stack
11
+ disabled-by-default-v8.cpu_profiler disabled-by-default-v8.cpu_profiler.hires].freeze
12
+ DEFAULT_TRACE_CONFIG = {
13
+ includedCategories: INCLUDED_CATEGORIES,
14
+ excludedCategories: EXCLUDED_CATEGORIES
15
+ }.freeze
16
+
17
+ def initialize(page)
18
+ @page = page
19
+ @subscribed_tracing_complete = false
20
+ end
21
+
22
+ #
23
+ # Accepts block, records trace and by default returns trace data from `Tracing.tracingComplete` event as output.
24
+ #
25
+ # @param [String, nil] path
26
+ # Save data on the disk.
27
+ #
28
+ # @param [:binary, :base64] encoding
29
+ # Encode output as Base64 or plain text.
30
+ #
31
+ # @param [Float, nil] timeout
32
+ # Wait until file streaming finishes in the specified time or raise
33
+ # error.
34
+ #
35
+ # @param [Boolean] screenshots
36
+ # capture screenshots in the trace.
37
+ #
38
+ # @param [Hash{String => Object}] trace_config
39
+ # config for [trace](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/#type-TraceConfig),
40
+ # for categories see [getCategories](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/#method-getCategories),
41
+ # only one trace config can be active at a time per browser.
42
+ #
43
+ # @return [String, true]
44
+ # The trace data from the `Tracing.tracingComplete` event.
45
+ # When `path` is specified returns `true` and stores trace data into
46
+ # file.
47
+ #
48
+ def record(path: nil, encoding: :binary, timeout: nil, trace_config: nil, screenshots: false)
49
+ @path = path
50
+ @encoding = encoding
51
+ @pending = Concurrent::IVar.new
52
+ trace_config ||= DEFAULT_TRACE_CONFIG.dup
53
+
54
+ if screenshots
55
+ included = trace_config.fetch(:includedCategories, [])
56
+ trace_config.merge!(includedCategories: included | SCREENSHOT_CATEGORIES)
57
+ end
58
+
59
+ subscribe_tracing_complete
60
+
61
+ start(trace_config)
62
+ yield
63
+ stop
64
+
65
+ @pending.value!(timeout || @page.timeout)
66
+ end
67
+
68
+ private
69
+
70
+ def start(config)
71
+ @page.command("Tracing.start", transferMode: "ReturnAsStream", traceConfig: config)
72
+ end
73
+
74
+ def stop
75
+ @page.command("Tracing.end")
76
+ end
77
+
78
+ def subscribe_tracing_complete
79
+ return if @subscribed_tracing_complete
80
+
81
+ @page.on("Tracing.tracingComplete") do |event, index|
82
+ next if index.to_i != 0
83
+
84
+ @pending.set(stream_handle(event["stream"]))
85
+ rescue StandardError => e
86
+ @pending.fail(e)
87
+ end
88
+
89
+ @subscribed_tracing_complete = true
90
+ end
91
+
92
+ def stream_handle(handle)
93
+ @page.stream_to(path: @path, encoding: @encoding, handle: handle)
94
+ end
95
+ end
96
+ end
97
+ end