Skip to content

Commit b61f21d

Browse files
authored
Merge pull request #189 from rubycdp/issue_171/implement_tracing
Implement Tracing
2 parents 70b5d85 + 9399d01 commit b61f21d

File tree

8 files changed

+324
-32
lines changed

8 files changed

+324
-32
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ a block with this page, after which the page is closed.
2222
- `Ferrum::Cookies#set` ability to set cookie using `Ferrum::Cookies::Cookie` object
2323
- `Ferrum::Network#emulate_network_conditions` activates emulation of network conditions
2424
- `Ferrum::Network#offline_mode` puts browser into offline mode
25+
- `Ferrum::Page#tracing` - instance of `Ferrum::Page::Tracing` for trace capabilities.
26+
- `Ferrum::Page::Tracing#record(&block)` start/stop tracing for steps provided in passed block
2527

2628
### Changed
2729

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ based on Ferrum and Mechanize.
4848
* [Dialogs](https://github.com/rubycdp/ferrum#dialogs)
4949
* [Animation](https://github.com/rubycdp/ferrum#animation)
5050
* [Node](https://github.com/rubycdp/ferrum#node)
51+
* [Tracing](https://github.com/rubycdp/ferrum#tracing)
5152
* [Thread safety](https://github.com/rubycdp/ferrum#thread-safety)
5253
* [Development](https://github.com/rubycdp/ferrum#development)
5354
* [Contributing](https://github.com/rubycdp/ferrum#contributing)
@@ -1148,6 +1149,33 @@ browser.at_xpath("//*[select]").select(["1", "2"])
11481149
```
11491150

11501151

1152+
## Tracing
1153+
1154+
You can use `tracing.record` to create a trace file which can be opened in Chrome DevTools or
1155+
[timeline viewer](https://chromedevtools.github.io/timeline-viewer/).
1156+
1157+
```ruby
1158+
page.tracing.record(path: "trace.json") do
1159+
page.go_to("https://www.google.com")
1160+
end
1161+
```
1162+
1163+
#### tracing.record(\*\*options) : `String`
1164+
1165+
Accepts block, records trace and by default returns trace data from `Tracing.tracingComplete` event as output. When
1166+
`path` is specified returns `true` and stores trace data into file.
1167+
1168+
* options `Hash`
1169+
* :path `String` save data on the disk, `nil` by default
1170+
* :encoding `Symbol` `:base64` | `:binary` encode output as Base64 or plain text. `:binary` by default
1171+
* :timeout `Float` wait until file streaming finishes in the specified time or raise error, defaults to `nil`
1172+
* :screenshots `Boolean` capture screenshots in the trace, `false` by default
1173+
* :trace_config `Hash<String, Object>` config for
1174+
[trace](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/#type-TraceConfig), for categories
1175+
see [getCategories](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/#method-getCategories),
1176+
only one trace config can be active at a time per browser.
1177+
1178+
11511179
## Thread safety ##
11521180

11531181
Ferrum is fully thread-safe. You can create one browser or a few as you wish and

lib/ferrum/page.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
require "ferrum/page/frames"
1111
require "ferrum/page/screenshot"
1212
require "ferrum/page/animation"
13+
require "ferrum/page/tracing"
14+
require "ferrum/page/stream"
1315
require "ferrum/browser/client"
1416

1517
module Ferrum
@@ -39,11 +41,13 @@ def reset
3941
include Animation
4042
include Screenshot
4143
include Frames
44+
include Stream
4245

4346
attr_accessor :referrer
4447
attr_reader :target_id, :browser,
4548
:headers, :cookies, :network,
46-
:mouse, :keyboard, :event
49+
:mouse, :keyboard, :event,
50+
:tracing
4751

4852
def initialize(target_id, browser)
4953
@frames = {}
@@ -62,6 +66,7 @@ def initialize(target_id, browser)
6266
@headers = Headers.new(self)
6367
@cookies = Cookies.new(self)
6468
@network = Network.new(self)
69+
@tracing = Tracing.new(self)
6570

6671
subscribe
6772
prepare_page

lib/ferrum/page/screenshot.rb

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ module Screenshot
2626
A6: { width: 4.13, height: 5.83 }
2727
}.freeze
2828

29-
STREAM_CHUNK = 128 * 1024
30-
3129
def screenshot(**opts)
3230
path, encoding = common_options(**opts)
3331
options = screenshot_options(path, **opts)
@@ -42,12 +40,7 @@ def pdf(**opts)
4240
path, encoding = common_options(**opts)
4341
options = pdf_options(**opts).merge(transferMode: "ReturnAsStream")
4442
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
43+
stream_to(path: path, encoding: encoding, handle: handle)
5144
end
5245

5346
def mhtml(path: nil)
@@ -78,29 +71,6 @@ def save_file(path, data)
7871
File.binwrite(path.to_s, data)
7972
end
8073

81-
def stream_to_file(handle, path:)
82-
File.open(path, "wb") { |f| stream_to(handle, f) }
83-
true
84-
end
85-
86-
def stream_to_memory(handle, encoding:)
87-
data = String.new("") # Mutable string has << and compatible to File
88-
stream_to(handle, data)
89-
encoding == :base64 ? Base64.encode64(data) : data
90-
end
91-
92-
def stream_to(handle, output)
93-
loop do
94-
result = command("IO.read", handle: handle, size: STREAM_CHUNK)
95-
96-
data_chunk = result["data"]
97-
data_chunk = Base64.decode64(data_chunk) if result["base64Encoded"]
98-
output << data_chunk
99-
100-
break if result["eof"]
101-
end
102-
end
103-
10474
def common_options(encoding: :base64, path: nil, **_)
10575
encoding = encoding.to_sym
10676
encoding = :binary if path

lib/ferrum/page/stream.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/ferrum/page/tracing.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
def record(path: nil, encoding: :binary, timeout: nil, trace_config: nil, screenshots: false)
23+
@path = path
24+
@encoding = encoding
25+
@result = Concurrent::Promises.resolvable_future
26+
trace_config ||= DEFAULT_TRACE_CONFIG.dup
27+
28+
if screenshots
29+
included = trace_config.fetch(:includedCategories, [])
30+
trace_config.merge!(includedCategories: included | SCREENSHOT_CATEGORIES)
31+
end
32+
33+
subscribe_tracing_complete
34+
35+
start(trace_config)
36+
yield
37+
stop
38+
39+
@result.value!(timeout)
40+
end
41+
42+
private
43+
44+
def start(config)
45+
@page.command("Tracing.start", transferMode: "ReturnAsStream", traceConfig: config)
46+
end
47+
48+
def stop
49+
@page.command("Tracing.end")
50+
end
51+
52+
def subscribe_tracing_complete
53+
return if @subscribed_tracing_complete
54+
55+
@page.on("Tracing.tracingComplete") do |event, index|
56+
next if index.to_i != 0
57+
58+
@result.fulfill(stream_handle(event["stream"]))
59+
rescue StandardError => e
60+
@result.reject(e)
61+
end
62+
63+
@subscribed_tracing_complete = true
64+
end
65+
66+
def stream_handle(handle)
67+
@page.stream_to(path: @path, encoding: @encoding, handle: handle)
68+
end
69+
end
70+
end
71+
end

spec/page/tracing_spec.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# frozen_string_literal: true
2+
3+
module Ferrum
4+
describe Page::Tracing do
5+
let(:file_path) { "#{PROJECT_ROOT}/spec/tmp/trace.json" }
6+
let(:file_path2) { "#{PROJECT_ROOT}/spec/tmp/trace2.json" }
7+
let(:file_path3) { "#{PROJECT_ROOT}/spec/tmp/trace3.json" }
8+
let(:content) { JSON.parse(File.read(file_path)) }
9+
let(:trace_config) { JSON.parse(content["metadata"]["trace-config"]) }
10+
11+
it "outputs a trace" do
12+
page.tracing.record(path: file_path) { page.go_to }
13+
14+
expect(File.exist?(file_path)).to be(true)
15+
ensure
16+
FileUtils.rm_f(file_path)
17+
end
18+
19+
it "runs with custom options" do
20+
page.tracing.record(
21+
path: file_path,
22+
trace_config: {
23+
includedCategories: ["disabled-by-default-devtools.timeline"],
24+
excludedCategories: ["*"]
25+
}
26+
) { page.go_to }
27+
28+
expect(File.exist?(file_path)).to be(true)
29+
expect(trace_config["excluded_categories"]).to eq(["*"])
30+
expect(trace_config["included_categories"]).to eq(["disabled-by-default-devtools.timeline"])
31+
expect(content["traceEvents"].any? { |o| o["cat"] == "toplevel" }).to eq(false)
32+
ensure
33+
FileUtils.rm_f(file_path)
34+
end
35+
36+
it "runs with default categories" do
37+
page.tracing.record(path: file_path) { page.go_to }
38+
39+
expect(File.exist?(file_path)).to be(true)
40+
expect(trace_config["excluded_categories"]).to eq(["*"])
41+
expect(trace_config["included_categories"])
42+
.to match_array(%w[devtools.timeline v8.execute disabled-by-default-devtools.timeline
43+
disabled-by-default-devtools.timeline.frame toplevel blink.console
44+
blink.user_timing latencyInfo disabled-by-default-devtools.timeline.stack
45+
disabled-by-default-v8.cpu_profiler disabled-by-default-v8.cpu_profiler.hires])
46+
expect(content["traceEvents"].any? { |o| o["cat"] == "toplevel" }).to eq(true)
47+
ensure
48+
FileUtils.rm_f(file_path)
49+
end
50+
51+
it "throws an exception if tracing is on two pages" do
52+
page.tracing.record(path: file_path) do
53+
page.go_to
54+
55+
expect do
56+
another = browser.create_page
57+
another.tracing.record(path: file_path2) { another.go_to }
58+
end.to raise_exception(Ferrum::BrowserError, "Tracing has already been started (possibly in another tab).")
59+
expect(File.exist?(file_path2)).to be(false)
60+
end
61+
62+
expect(File.exist?(file_path)).to be(true)
63+
end
64+
65+
it "handles tracing complete event once" do
66+
expect(page.tracing).to receive(:stream_handle).exactly(3).times.and_call_original
67+
68+
page.tracing.record(path: file_path) { page.go_to }
69+
expect(File.exist?(file_path)).to be(true)
70+
71+
page.tracing.record(path: file_path2) { page.go_to }
72+
expect(File.exist?(file_path2)).to be(true)
73+
74+
page.tracing.record(path: file_path3) { page.go_to }
75+
expect(File.exist?(file_path3)).to be(true)
76+
ensure
77+
FileUtils.rm_f(file_path)
78+
FileUtils.rm_f(file_path2)
79+
FileUtils.rm_f(file_path3)
80+
end
81+
82+
it "returns base64 encoded string" do
83+
trace = page.tracing.record(encoding: :base64) { page.go_to }
84+
85+
decoded = Base64.decode64(trace)
86+
content = JSON.parse(decoded)
87+
expect(content["traceEvents"].any?).to eq(true)
88+
end
89+
90+
it "returns buffer with no encoding" do
91+
trace = page.tracing.record { page.go_to }
92+
93+
content = JSON.parse(trace)
94+
expect(content["traceEvents"].any?).to eq(true)
95+
end
96+
97+
context "screenshots enabled" do
98+
it "fills file with screenshot data" do
99+
page.tracing.record(path: file_path, screenshots: true) { page.go_to("/ferrum/grid") }
100+
101+
expect(File.exist?(file_path)).to be(true)
102+
expect(trace_config["included_categories"]).to include("disabled-by-default-devtools.screenshot")
103+
expect(content["traceEvents"].any? { |o| o["name"] == "Screenshot" }).to eq(true)
104+
ensure
105+
FileUtils.rm_f(file_path)
106+
end
107+
108+
it "returns a buffer with screenshot data" do
109+
trace = page.tracing.record(screenshots: true) { page.go_to("/ferrum/grid") }
110+
111+
expect(File.exist?(file_path)).to be(false)
112+
content = JSON.parse(trace)
113+
trace_config = JSON.parse(content["metadata"]["trace-config"])
114+
expect(trace_config["included_categories"]).to include("disabled-by-default-devtools.screenshot")
115+
expect(content["traceEvents"].any? { |o| o["name"] == "Screenshot" }).to eq(true)
116+
end
117+
end
118+
119+
it "waits for promise fill with timeout when it provided" do
120+
expect(page.tracing).to receive(:subscribe_tracing_complete).with(no_args)
121+
trace = page.tracing.record(timeout: 1) { page.go_to }
122+
expect(trace).to be_nil
123+
end
124+
end
125+
end

0 commit comments

Comments
 (0)