From 24b914343d87cb842183509801894f2248271d6f Mon Sep 17 00:00:00 2001 From: Aleksey Strizhak Date: Fri, 20 Aug 2021 12:46:12 +0500 Subject: [PATCH] Implement tracing --- README.md | 45 ++++++++++ lib/ferrum.rb | 1 + lib/ferrum/page.rb | 5 ++ lib/ferrum/page/screenshot.rb | 30 +------ lib/ferrum/page/tracing.rb | 82 ++++++++++++++++++ lib/ferrum/utils/stream.rb | 43 +++++++++ spec/browser_spec.rb | 158 ++++++++++++++++++++++++++++++++++ spec/support/views/grid.erb | 53 ++++++++++++ 8 files changed, 389 insertions(+), 28 deletions(-) create mode 100644 lib/ferrum/page/tracing.rb create mode 100644 lib/ferrum/utils/stream.rb create mode 100644 spec/support/views/grid.erb 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 @@ + + + +