diff --git a/README.md b/README.md
index ee7ab7d6..7fe7f034 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ based on Ferrum and Mechanize.
* [Dialogs](https://github.com/rubycdp/ferrum#dialogs)
* [Animation](https://github.com/rubycdp/ferrum#animation)
* [Node](https://github.com/rubycdp/ferrum#node)
+* [Tracing](https://github.com/rubycdp/ferrum#tracing)
* [Thread safety](https://github.com/rubycdp/ferrum#thread-safety)
* [Development](https://github.com/rubycdp/ferrum#development)
* [Contributing](https://github.com/rubycdp/ferrum#contributing)
@@ -1148,6 +1149,50 @@ browser.at_xpath("//*[select]").select(["1", "2"])
```
+## Tracing
+
+You can use `tracing.record` to create a trace file which can be opened in Chrome DevTools or [timeline viewer](https://chromedevtools.github.io/timeline-viewer/).
+
+```ruby
+browser.page.tracing.record(path: "trace.json") do
+ browser.go_to("https://www.google.com")
+end
+```
+
+#### tracing.record(\*\*options) : `Hash`
+
+- By default: returns Trace data from `Tracing.tracingComplete` event.
+- When `path` specified: return `true` and store Trace data from `Tracing.tracingComplete` event to file.
+
+* options `Hash`
+ * `:path` (String) - `String` to save a Trace data output on the disk, not specified by default.
+ * `:encoding` (Symbol) - `:base64` | `:binary` setting only for memory Trace data output, `:binary` by default.
+ * `:timeout` (Integer) - [Timeout of promise](https://github.com/ruby-concurrency/concurrent-ruby/blob/52c08fca13cc3811673ea2f6fdb244a0e42e0ebe/lib/concurrent-ruby/concurrent/promises.rb#L986) to wait til event `Tracing.Complete` triggered that fills buffer/file, `nil` by default, means "wait forever".
+ * `:screenshots` (Boolean) - When true - Captures screenshots in the trace, `false` by default.
+ * `:included_categories` (Array[String]) - An array of categories that be included to tracing data, by default:
+ ```ruby
+ ["devtools.timeline",
+ "v8.execute",
+ "disabled-by-default-devtools.timeline",
+ "disabled-by-default-devtools.timeline.frame",
+ "toplevel",
+ "blink.console",
+ "blink.user_timing",
+ "latencyInfo",
+ "disabled-by-default-devtools.timeline.stack",
+ "disabled-by-default-v8.cpu_profiler",
+ "disabled-by-default-v8.cpu_profiler.hires"]
+ ```
+ * `:excluded_categories` (Array[String]) - An array of categories that be excluded from tracing data, by default:
+ ```ruby
+ ["*"]
+ ```
+
+ See all categories by `browser.client.command("Tracing.getCategories")`
+
+Only one trace can be active at a time per browser.
+
+
## Thread safety ##
Ferrum is fully thread-safe. You can create one browser or a few as you wish and
diff --git a/lib/ferrum.rb b/lib/ferrum.rb
index 6354f2b5..2e6a0368 100644
--- a/lib/ferrum.rb
+++ b/lib/ferrum.rb
@@ -3,6 +3,7 @@
require "ferrum/utils/platform"
require "ferrum/utils/elapsed_time"
require "ferrum/utils/attempt"
+require "ferrum/utils/stream"
require "ferrum/errors"
require "ferrum/browser"
require "ferrum/node"
diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb
index fe66fe5d..11d54ac6 100644
--- a/lib/ferrum/page.rb
+++ b/lib/ferrum/page.rb
@@ -10,6 +10,7 @@
require "ferrum/page/frames"
require "ferrum/page/screenshot"
require "ferrum/page/animation"
+require "ferrum/page/tracing"
require "ferrum/browser/client"
module Ferrum
@@ -215,6 +216,10 @@ def subscribed?(event)
@client.subscribed?(event)
end
+ def tracing
+ @tracing ||= Tracing.new(client: @client)
+ end
+
private
def subscribe
diff --git a/lib/ferrum/page/screenshot.rb b/lib/ferrum/page/screenshot.rb
index c2d86c0c..37d04178 100644
--- a/lib/ferrum/page/screenshot.rb
+++ b/lib/ferrum/page/screenshot.rb
@@ -42,11 +42,8 @@ def pdf(**opts)
path, encoding = common_options(**opts)
options = pdf_options(**opts).merge(transferMode: "ReturnAsStream")
handle = command("Page.printToPDF", **options).fetch("stream")
-
- if path
- stream_to_file(handle, path: path)
- else
- stream_to_memory(handle, encoding: encoding)
+ Utils::Stream.fetch(encoding: encoding, path: path) do |read_stream|
+ read_stream.call(client: self, handle: handle)
end
end
@@ -78,29 +75,6 @@ def save_file(path, data)
File.binwrite(path.to_s, data)
end
- def stream_to_file(handle, path:)
- File.open(path, "wb") { |f| stream_to(handle, f) }
- true
- end
-
- def stream_to_memory(handle, encoding:)
- data = String.new("") # Mutable string has << and compatible to File
- stream_to(handle, data)
- encoding == :base64 ? Base64.encode64(data) : data
- end
-
- def stream_to(handle, output)
- loop do
- result = command("IO.read", handle: handle, size: STREAM_CHUNK)
-
- data_chunk = result["data"]
- data_chunk = Base64.decode64(data_chunk) if result["base64Encoded"]
- output << data_chunk
-
- break if result["eof"]
- end
- end
-
def common_options(encoding: :base64, path: nil, **_)
encoding = encoding.to_sym
encoding = :binary if path
diff --git a/lib/ferrum/page/tracing.rb b/lib/ferrum/page/tracing.rb
new file mode 100644
index 00000000..bd4d26c2
--- /dev/null
+++ b/lib/ferrum/page/tracing.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module Ferrum
+ class Page
+ class Tracing
+ INCLUDED_CATEGORIES = %w[
+ devtools.timeline
+ v8.execute
+ disabled-by-default-devtools.timeline
+ disabled-by-default-devtools.timeline.frame
+ toplevel
+ blink.console
+ blink.user_timing
+ latencyInfo
+ disabled-by-default-devtools.timeline.stack
+ disabled-by-default-v8.cpu_profiler
+ disabled-by-default-v8.cpu_profiler.hires
+ ].freeze
+ EXCLUDED_CATEGORIES = %w[
+ *
+ ].freeze
+
+ def initialize(client:)
+ @client = client
+ end
+
+ def record(options = {}, &block)
+ @options = {
+ timeout: nil,
+ screenshots: false,
+ encoding: :binary,
+ included_categories: INCLUDED_CATEGORIES,
+ excluded_categories: EXCLUDED_CATEGORIES,
+ **options
+ }
+ @promise = Concurrent::Promises.resolvable_future
+ subscribe_on_tracing_event
+ start
+ block.call
+ @client.command("Tracing.end")
+ @promise.value!(@options[:timeout])
+ end
+
+ private
+
+ def start
+ @client.command(
+ "Tracing.start",
+ transferMode: "ReturnAsStream",
+ traceConfig: {
+ includedCategories: included_categories,
+ excludedCategories: @options[:excluded_categories]
+ }
+ )
+ end
+
+ def included_categories
+ included_categories = @options[:included_categories]
+ if @options[:screenshots] == true
+ included_categories = @options[:included_categories] | ["disabled-by-default-devtools.screenshot"]
+ end
+ included_categories
+ end
+
+ def subscribe_on_tracing_event
+ @client.on("Tracing.tracingComplete") do |event, index|
+ next if index.to_i != 0
+
+ @promise.fulfill(stream(event.fetch("stream")))
+ rescue StandardError => e
+ @promise.reject(e)
+ end
+ end
+
+ def stream(handle)
+ Utils::Stream.fetch(encoding: @options[:encoding], path: @options[:path]) do |read_stream|
+ read_stream.call(client: @client, handle: handle)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ferrum/utils/stream.rb b/lib/ferrum/utils/stream.rb
new file mode 100644
index 00000000..2b689eb9
--- /dev/null
+++ b/lib/ferrum/utils/stream.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Ferrum
+ module Utils
+ module Stream
+ STREAM_CHUNK = 128 * 1024
+
+ module_function
+
+ def fetch(path:, encoding:, &block)
+ if path.nil?
+ stream_to_memory(encoding: encoding, &block)
+ else
+ stream_to_file(path: path, &block)
+ end
+ end
+
+ def stream_to_file(path:, &block)
+ File.open(path, "wb") { |f| stream_to(f, &block) }
+ true
+ end
+
+ def stream_to_memory(encoding:, &block)
+ data = String.new("") # Mutable string has << and compatible to File
+ stream_to(data, &block)
+ encoding == :base64 ? Base64.encode64(data) : data
+ end
+
+ def stream_to(output, &block)
+ loop do
+ read_stream = lambda do |client:, handle:|
+ client.command("IO.read", handle: handle, size: STREAM_CHUNK)
+ end
+ result = block.call(read_stream)
+ data_chunk = result["data"]
+ data_chunk = Base64.decode64(data_chunk) if result["base64Encoded"]
+ output << data_chunk
+ break if result["eof"]
+ end
+ end
+ end
+ end
+end
diff --git a/spec/browser_spec.rb b/spec/browser_spec.rb
index ca3e4826..3f88a778 100644
--- a/spec/browser_spec.rb
+++ b/spec/browser_spec.rb
@@ -824,5 +824,163 @@ module Ferrum
end
end
end
+
+ context "tracing" do
+ let(:file_path) { "#{PROJECT_ROOT}/spec/tmp/trace.json" }
+
+ it "outputs a trace" do
+ browser.page.tracing.record(path: file_path) do
+ browser.go_to("https://www.google.com")
+ end
+ expect(File.exist?(file_path)).to be(true)
+ ensure
+ FileUtils.rm_f(file_path)
+ end
+
+ it "runs with custom options" do
+ browser.page.tracing.record(
+ path: file_path,
+ included_categories: ["disabled-by-default-devtools.timeline"],
+ excluded_categories: ["*"]
+ ) do
+ browser.go_to
+ end
+ expect(File.exist?(file_path)).to be(true)
+ content = File.read(file_path)
+ trace_config = JSON.parse(content)["metadata"]["trace-config"]
+ expect(JSON.parse(trace_config)["excluded_categories"]).to eq(["*"])
+ expect(JSON.parse(trace_config)["included_categories"]).to eq(["disabled-by-default-devtools.timeline"])
+ expect(JSON.parse(content)["traceEvents"].any? { |object| object["cat"] == "toplevel" }).to eq(false)
+ ensure
+ FileUtils.rm_f(file_path)
+ end
+
+ it "runs with default categories" do
+ browser.page.tracing.record(path: file_path) do
+ browser.go_to
+ end
+ expect(File.exist?(file_path)).to be(true)
+ content = File.read(file_path)
+ trace_config = JSON.parse(content)["metadata"]["trace-config"]
+ expect(JSON.parse(trace_config)["excluded_categories"]).to eq(["*"])
+ expect(JSON.parse(trace_config)["included_categories"])
+ .to match_array(%w[
+ devtools.timeline
+ v8.execute
+ disabled-by-default-devtools.timeline
+ disabled-by-default-devtools.timeline.frame
+ toplevel
+ blink.console
+ blink.user_timing
+ latencyInfo
+ disabled-by-default-devtools.timeline.stack
+ disabled-by-default-v8.cpu_profiler
+ disabled-by-default-v8.cpu_profiler.hires
+ ])
+ expect(JSON.parse(content)["traceEvents"].any? { |object| object["cat"] == "toplevel" }).to eq(true)
+ ensure
+ FileUtils.rm_f(file_path)
+ end
+
+ it "throws an exception if tracing on two pages" do
+ page = browser.create_page
+ browser.page.tracing.record(path: file_path) do
+ expect do
+ page.tracing.record(path: file_path) do
+ browser.go_to
+ end
+ end.to raise_exception(Ferrum::BrowserError) do |e|
+ expect(e.message).to eq("Tracing has already been started (possibly in another tab).")
+ end
+ end
+ end
+
+ specify "handles tracing.Complete event once" do
+ file_path = "#{PROJECT_ROOT}/spec/tmp/trace.json"
+ browser.page.tracing.record(path: file_path) do
+ browser.go_to
+ expect(Utils::Stream).to receive(:stream_to_file).with(path: file_path).once.and_call_original
+ end
+ expect(File.exist?(file_path)).to be(true)
+ file_path2 = "#{PROJECT_ROOT}/spec/tmp/trace2.json"
+ browser.page.tracing.record(path: file_path2) do
+ browser.go_to
+ expect(Utils::Stream).to receive(:stream_to_file).with(path: file_path2).once.and_call_original
+ end
+ expect(File.exist?(file_path2)).to be(true)
+ file_path3 = "#{PROJECT_ROOT}/spec/tmp/trace3.json"
+ browser.page.tracing.record(path: file_path3) do
+ browser.go_to
+ expect(Utils::Stream).to receive(:stream_to_file).with(path: file_path3).once.and_call_original
+ end
+ expect(File.exist?(file_path3)).to be(true)
+ ensure
+ FileUtils.rm_f(file_path)
+ FileUtils.rm_f(file_path2)
+ FileUtils.rm_f(file_path3)
+ end
+
+ it "returns encoded 64 buffer" do
+ trace = browser.page.tracing.record(encoding: :base64) do
+ browser.go_to
+ end
+ expect(File.exist?(file_path)).to be(false)
+ decode64_trace = Base64.decode64(trace)
+ expect(JSON.parse(decode64_trace)["traceEvents"].any?).to eq(true)
+ end
+
+ it "returns buffer with no encoding" do
+ trace = browser.page.tracing.record do
+ browser.go_to
+ end
+ expect(File.exist?(file_path)).to be(false)
+ expect(JSON.parse(trace)["traceEvents"].any?).to eq(true)
+ end
+
+ context "screenshots enabled" do
+ it "fills file with screenshot data" do
+ browser.page.tracing.record(path: file_path, screenshots: true) do
+ browser.go_to("/ferrum/grid")
+ end
+ expect(File.exist?(file_path)).to be(true)
+ content = JSON.parse(File.read(file_path))
+ trace_events = content["traceEvents"]
+ trace_config = content["metadata"]["trace-config"]
+ expect(JSON.parse(trace_config)["included_categories"]).to include("disabled-by-default-devtools.screenshot")
+ expect(trace_events.any? { |object| object["name"] == "Screenshot" }).to eq(true)
+ ensure
+ FileUtils.rm_f(file_path)
+ end
+
+ it "returns a buffer with screenshot data" do
+ trace = browser.page.tracing.record(screenshots: true) do
+ browser.go_to("/ferrum/grid")
+ end
+ expect(File.exist?(file_path)).to be(false)
+ trace_config = JSON.parse(trace)["metadata"]["trace-config"]
+ expect(JSON.parse(trace_config)["included_categories"]).to include("disabled-by-default-devtools.screenshot")
+ expect(JSON.parse(trace)["traceEvents"].any? { |object| object["name"] == "Screenshot" }).to eq(true)
+ end
+ end
+
+ it "fails with provided error on any errors in stream output" do
+ execute_error = StandardError.new("error message")
+ expect do
+ browser.page.tracing.record(path: file_path) do
+ browser.go_to
+ expect(browser.page.tracing).to receive(:stream).with(kind_of(String)).once.and_raise(execute_error)
+ end
+ end.to raise_exception(execute_error, "error message")
+ expect(File.exist?(file_path)).to be(false)
+ end
+
+ it "waits for promise fill with timeout when it provided" do
+ expect(browser.page.tracing).to receive(:subscribe_on_tracing_event).with(no_args)
+ trace = browser.page.tracing.record(timeout: 1) do
+ browser.go_to
+ end
+ expect(trace).to be_nil
+ end
+ end
end
end
diff --git a/spec/support/views/grid.erb b/spec/support/views/grid.erb
new file mode 100644
index 00000000..0a68a07a
--- /dev/null
+++ b/spec/support/views/grid.erb
@@ -0,0 +1,53 @@
+
+
+
+