chrome_debugger 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +2 -0
- data/README.md +59 -0
- data/examples/basic.rb +34 -0
- data/examples/theconversation.rb +57 -0
- data/lib/chrome_debugger.rb +1 -0
- data/lib/chrome_debugger/client.rb +136 -0
- data/lib/chrome_debugger/data_received.rb +19 -0
- data/lib/chrome_debugger/document.rb +119 -0
- data/lib/chrome_debugger/dom_content_event_fired.rb +10 -0
- data/lib/chrome_debugger/load_event_fired.rb +11 -0
- data/lib/chrome_debugger/notification.rb +20 -0
- data/lib/chrome_debugger/request_will_be_sent.rb +14 -0
- data/lib/chrome_debugger/response_received.rb +11 -0
- metadata +63 -0
data/CHANGELOG
ADDED
data/README.md
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# Chrome Debugger
|
2
|
+
|
3
|
+
Ever wanted to capture stats (#requests, onload time, etc.) about the state of your frontend? Us too!
|
4
|
+
Chrome Debugger uses the remote debugging protocol in Chrome to do just that!
|
5
|
+
|
6
|
+
Intended to be a used in a post-deploy or CI step.
|
7
|
+
|
8
|
+
Yay-hooray!
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
gem install chrome_debugger
|
13
|
+
|
14
|
+
## Extra Requirements
|
15
|
+
|
16
|
+
Chrome 18 or higher must be installed and available on the path.
|
17
|
+
|
18
|
+
## Usage
|
19
|
+
|
20
|
+
require 'chrome_debugger'
|
21
|
+
|
22
|
+
ChromeDebugger::Client.open do |chrome|
|
23
|
+
document = chrome.load_url("https://theconversation.edu.au/")
|
24
|
+
|
25
|
+
puts "requests: #{document.request_count}"
|
26
|
+
puts "onload_event: #{document.onload_event}"
|
27
|
+
puts "dom_content_event: #{document.dom_content_event}"
|
28
|
+
puts "document_payload: #{document.encoded_bytes("Document")}"
|
29
|
+
puts "script_payload: #{document.encoded_bytes("Script")}"
|
30
|
+
puts "image_payload: #{document.encoded_bytes("Image")}"
|
31
|
+
end
|
32
|
+
|
33
|
+
Refer to the ChromeDebugger::Client and ChromeDebugger::Document classes for
|
34
|
+
detailed docs.
|
35
|
+
|
36
|
+
ChromeDebugger::Client starts and manages a new chrome session.
|
37
|
+
|
38
|
+
ChromeDebugger::Document provides an entry point for querying the results of
|
39
|
+
a page load.
|
40
|
+
|
41
|
+
## Authors
|
42
|
+
|
43
|
+
Justin Morris
|
44
|
+
justin.morris@theconversation.edu.au
|
45
|
+
|
46
|
+
James Healy
|
47
|
+
james.healy@theconversation.edu.au
|
48
|
+
|
49
|
+
## Further Reading
|
50
|
+
|
51
|
+
* https://developers.google.com/chrome-developer-tools/docs/remote-debugging
|
52
|
+
* http://www.igvita.com/2012/04/09/driving-google-chrome-via-websocket-api/
|
53
|
+
|
54
|
+
## TODO
|
55
|
+
|
56
|
+
Possible further work.
|
57
|
+
|
58
|
+
* make the chrome path configurable
|
59
|
+
* make headless mode configurable
|
data/examples/basic.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
# A basic example that prints stats on a website to STDOUT.
|
4
|
+
#
|
5
|
+
# Usage:
|
6
|
+
#
|
7
|
+
# ruby basic.rb
|
8
|
+
|
9
|
+
$:.unshift File.expand_path('../../lib/', __FILE__)
|
10
|
+
require 'chrome_debugger'
|
11
|
+
|
12
|
+
ChromeDebugger::Client.open do |chrome|
|
13
|
+
document = chrome.load_url("https://theconversation.edu.au/")
|
14
|
+
{
|
15
|
+
requests: document.request_count,
|
16
|
+
onload_event: document.onload_event,
|
17
|
+
dom_content_event: document.dom_content_event,
|
18
|
+
document_payload: document.encoded_bytes("Document"),
|
19
|
+
script_payload: document.encoded_bytes("Script"),
|
20
|
+
image_payload: document.encoded_bytes("Image"),
|
21
|
+
stylesheet_payload: document.encoded_bytes("Stylesheet"),
|
22
|
+
other_payload: document.encoded_bytes("Other"),
|
23
|
+
document_uncompressed_payload: document.bytes("Document"),
|
24
|
+
script_uncompressed_payload: document.bytes("Script"),
|
25
|
+
image_uncompressed_payload: document.bytes("Image"),
|
26
|
+
stylesheet_uncompressed_payload: document.bytes("Stylesheet"),
|
27
|
+
other_uncompressed_payload: document.bytes("Other"),
|
28
|
+
script_count: document.request_count_by_resource("Script"),
|
29
|
+
image_count: document.request_count_by_resource("Image"),
|
30
|
+
stylesheet_count: document.request_count_by_resource("Stylesheet")
|
31
|
+
}.each {|key, value|
|
32
|
+
puts "#{key}: #{value}"
|
33
|
+
}
|
34
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
|
3
|
+
# Demonstrates how we use ChromeDebugger at @ConversationEdu. We track various
|
4
|
+
# performance metrics on key pages and send the results to Librato Metrics, a
|
5
|
+
# hosted metrics platform.
|
6
|
+
#
|
7
|
+
# Usage:
|
8
|
+
#
|
9
|
+
# LIBRATO_USER=someone@example.com LIBRATO_KEY=SECRET ruby theconversation.rb
|
10
|
+
|
11
|
+
require 'chrome_debugger'
|
12
|
+
require 'librato/metrics'
|
13
|
+
|
14
|
+
# Config
|
15
|
+
|
16
|
+
LIBRATO_PREFIX = "tc.frontend"
|
17
|
+
LIBRATO_USER = ENV["LIBRATO_USER"]
|
18
|
+
LIBRATO_KEY = ENV["LIBRATO_KEY"]
|
19
|
+
PAGES = {
|
20
|
+
homepage: 'https://theconversation.edu.au/',
|
21
|
+
articlepage: 'https://theconversation.edu.au/bike-lanes-economic-benefits-go-beyond-jobs-6081/'
|
22
|
+
}
|
23
|
+
|
24
|
+
# ACTION
|
25
|
+
|
26
|
+
Librato::Metrics.authenticate(LIBRATO_USER, LIBRATO_KEY)
|
27
|
+
librato_queue = Librato::Metrics::Queue.new
|
28
|
+
|
29
|
+
PAGES.each do |name, url|
|
30
|
+
ChromeDebugger::Client.open do |chrome|
|
31
|
+
document = chrome.load_url(url)
|
32
|
+
{
|
33
|
+
requests: document.request_count,
|
34
|
+
onload_event: document.onload_event,
|
35
|
+
dom_content_event: document.dom_content_event,
|
36
|
+
document_payload: document.encoded_bytes("Document"),
|
37
|
+
script_payload: document.encoded_bytes("Script"),
|
38
|
+
image_payload: document.encoded_bytes("Image"),
|
39
|
+
stylesheet_payload: document.encoded_bytes("Stylesheet"),
|
40
|
+
other_payload: document.encoded_bytes("Other"),
|
41
|
+
document_uncompressed_payload: document.bytes("Document"),
|
42
|
+
script_uncompressed_payload: document.bytes("Script"),
|
43
|
+
image_uncompressed_payload: document.bytes("Image"),
|
44
|
+
stylesheet_uncompressed_payload: document.bytes("Stylesheet"),
|
45
|
+
other_uncompressed_payload: document.bytes("Other"),
|
46
|
+
script_count: document.request_count_by_resource("Script"),
|
47
|
+
image_count: document.request_count_by_resource("Image"),
|
48
|
+
stylesheet_count: document.request_count_by_resource("Stylesheet")
|
49
|
+
}.each {|key, value|
|
50
|
+
puts "#{LIBRATO_PREFIX}.#{name}.#{key}: #{value}"
|
51
|
+
}.each { |key, value|
|
52
|
+
librato_queue.add("#{LIBRATO_PREFIX}.#{name}.#{key}" => value)
|
53
|
+
}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
librato_queue.submit
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'chrome_debugger/client'
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'em-http'
|
2
|
+
require 'faye/websocket'
|
3
|
+
require 'headless'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
require 'securerandom'
|
7
|
+
|
8
|
+
require 'chrome_debugger/document'
|
9
|
+
require 'chrome_debugger/notification'
|
10
|
+
require 'chrome_debugger/data_received'
|
11
|
+
require 'chrome_debugger/dom_content_event_fired'
|
12
|
+
require 'chrome_debugger/load_event_fired'
|
13
|
+
require 'chrome_debugger/request_will_be_sent'
|
14
|
+
require 'chrome_debugger/response_received'
|
15
|
+
|
16
|
+
module ChromeDebugger
|
17
|
+
class Client
|
18
|
+
|
19
|
+
PAGE_LOAD_WAIT = 16
|
20
|
+
REMOTE_DEBUGGING_PORT = 9222
|
21
|
+
|
22
|
+
def initialize(opts = {})
|
23
|
+
@chrome_path = find_chrome_binary
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.open(&block)
|
27
|
+
headless = Headless.new
|
28
|
+
headless.start
|
29
|
+
chrome = ChromeDebugger::Client.new
|
30
|
+
chrome.start_chrome
|
31
|
+
yield chrome
|
32
|
+
ensure
|
33
|
+
chrome.cleanup
|
34
|
+
headless.destroy
|
35
|
+
end
|
36
|
+
|
37
|
+
def start_chrome
|
38
|
+
@profile_dir = File.join(Dir.tmpdir, SecureRandom.hex(10))
|
39
|
+
@chrome_cmd = "'#{@chrome_path}' --user-data-dir=#{@profile_dir} -remote-debugging-port=#{REMOTE_DEBUGGING_PORT} --no-first-run"
|
40
|
+
@chrome_pid = Process.spawn(@chrome_cmd, :pgroup => true)
|
41
|
+
|
42
|
+
until debug_port_listening?
|
43
|
+
sleep 0.1
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def load_url(url)
|
48
|
+
raise "call the start_chrome() method first" unless @chrome_pid
|
49
|
+
document = ChromeDebugger::Document.new(url)
|
50
|
+
load(document)
|
51
|
+
document
|
52
|
+
end
|
53
|
+
|
54
|
+
def cleanup
|
55
|
+
if @chrome_pid
|
56
|
+
Process.kill('-TERM', Process.getpgid(@chrome_pid))
|
57
|
+
sleep 3
|
58
|
+
FileUtils.rm_rf(@profile_dir) if @profile_dir && File.directory?(@profile_dir)
|
59
|
+
@chrome_pid = nil
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def find_chrome_binary
|
66
|
+
path = [
|
67
|
+
"/usr/bin/google-chrome",
|
68
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
69
|
+
].detect { |path|
|
70
|
+
File.file?(path)
|
71
|
+
}
|
72
|
+
raise "No Chrome binary found" if path.nil?
|
73
|
+
path
|
74
|
+
end
|
75
|
+
|
76
|
+
def handle_data(document, data)
|
77
|
+
unless data['result']
|
78
|
+
case data['method']
|
79
|
+
when "Network.requestWillBeSent" then
|
80
|
+
# The browser is initiating a new HTTP request
|
81
|
+
document.events << ChromeDebugger::RequestWillBeSent.new(data)
|
82
|
+
when "Page.domContentEventFired" then
|
83
|
+
document.events << ChromeDebugger::DomContentEventFired.new(data)
|
84
|
+
when "Page.loadEventFired" then
|
85
|
+
document.events << ChromeDebugger::LoadEventFired.new(data)
|
86
|
+
when "Network.responseReceived" then
|
87
|
+
document.events << ChromeDebugger::ResponseReceived.new(data)
|
88
|
+
when "Network.dataReceived" then
|
89
|
+
document.events << ChromeDebugger::DataReceived.new(data)
|
90
|
+
else
|
91
|
+
document.events << ChromeDebugger::Notification.new(data)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def load(document)
|
97
|
+
EM.run do
|
98
|
+
EM::add_periodic_timer(0.5) do
|
99
|
+
EM.stop_event_loop if document.onload_event
|
100
|
+
end
|
101
|
+
|
102
|
+
conn = EM::HttpRequest.new("http://localhost:#{REMOTE_DEBUGGING_PORT}/json").get
|
103
|
+
conn.callback do
|
104
|
+
response = JSON.parse(conn.response)
|
105
|
+
|
106
|
+
ws = Faye::WebSocket::Client.new response.first['webSocketDebuggerUrl']
|
107
|
+
|
108
|
+
ws.onmessage = lambda do |message|
|
109
|
+
data = JSON.parse(message.data)
|
110
|
+
handle_data(document, data)
|
111
|
+
end
|
112
|
+
|
113
|
+
ws.onopen = lambda do |event|
|
114
|
+
ws.send JSON.dump({id: 1, method: 'Page.enable'})
|
115
|
+
ws.send JSON.dump({id: 2, method: 'Network.enable'})
|
116
|
+
ws.send JSON.dump({id: 3, method: 'Network.setCacheDisabled', params: {cacheDisabled: true}})
|
117
|
+
ws.send JSON.dump({id: 4, method: 'Network.clearBrowserCache'})
|
118
|
+
ws.send JSON.dump({
|
119
|
+
id: 5,
|
120
|
+
method: 'Page.navigate',
|
121
|
+
params: {url: document.url}
|
122
|
+
})
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def debug_port_listening?
|
129
|
+
TCPSocket.new('localhost', REMOTE_DEBUGGING_PORT).close
|
130
|
+
true
|
131
|
+
rescue Errno::ECONNREFUSED
|
132
|
+
false
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'chrome_debugger/notification'
|
2
|
+
|
3
|
+
module ChromeDebugger
|
4
|
+
class DataReceived < Notification
|
5
|
+
|
6
|
+
# uncompressed bytes in the HTTP content. Excludes HTTP headers
|
7
|
+
#
|
8
|
+
def data_length
|
9
|
+
@params['dataLength'].to_i
|
10
|
+
end
|
11
|
+
|
12
|
+
# bytes received over the wire. Includes HTTP headers
|
13
|
+
# and HTTP content. The HTTP content may be compressed.
|
14
|
+
#
|
15
|
+
def encoded_data_length
|
16
|
+
@params['encodedDataLength'].to_i
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
require 'chrome_debugger/dom_content_event_fired'
|
2
|
+
require 'chrome_debugger/load_event_fired'
|
3
|
+
require 'chrome_debugger/notification'
|
4
|
+
require 'chrome_debugger/response_received'
|
5
|
+
require 'chrome_debugger/request_will_be_sent'
|
6
|
+
|
7
|
+
module ChromeDebugger
|
8
|
+
class Document
|
9
|
+
|
10
|
+
attr_reader :url, :events
|
11
|
+
|
12
|
+
def initialize(url)
|
13
|
+
@url = url
|
14
|
+
@timestamp = 0
|
15
|
+
@events = []
|
16
|
+
end
|
17
|
+
|
18
|
+
# The seconds since epoch that the request for this document started
|
19
|
+
#
|
20
|
+
def start_time
|
21
|
+
@start_time ||= @events.select { |event|
|
22
|
+
event.is_a?(RequestWillBeSent)
|
23
|
+
}.select { |event|
|
24
|
+
event.request['url'] == @url
|
25
|
+
}.map { |event|
|
26
|
+
event.timestamp
|
27
|
+
}.first
|
28
|
+
end
|
29
|
+
|
30
|
+
# The number of seconds *after* start_time that the OnLoad event fired
|
31
|
+
#
|
32
|
+
def onload_event
|
33
|
+
@onload_event ||= begin
|
34
|
+
ts = @events.select { |event|
|
35
|
+
event.is_a?(LoadEventFired)
|
36
|
+
}.slice(0,1).map(&:timestamp).first
|
37
|
+
ts ? (ts - start_time).round(3) : nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# The number of seconds *after* start_time the the DomReady event fired
|
42
|
+
#
|
43
|
+
def dom_content_event
|
44
|
+
@dom_content_event ||= begin
|
45
|
+
ts = @events.select { |event|
|
46
|
+
event.is_a?(DomContentEventFired)
|
47
|
+
}.slice(0,1).map(&:timestamp).first
|
48
|
+
ts ? (ts - start_time).round(3) : nil
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# The number of bytes downloaded for a particular resource type. If the
|
53
|
+
# resource was gzipped during transfer then the gzipped size is reported.
|
54
|
+
#
|
55
|
+
# The HTTP headers for the response are included in the byte count.
|
56
|
+
#
|
57
|
+
# Possible resource types: 'Document','Script', 'Image', 'Stylesheet',
|
58
|
+
# 'Other'.
|
59
|
+
#
|
60
|
+
def encoded_bytes(resource_type)
|
61
|
+
@events.select {|e|
|
62
|
+
e.is_a?(ResponseReceived) && e.resource_type == resource_type
|
63
|
+
}.map { |e|
|
64
|
+
e.request_id
|
65
|
+
}.map { |request_id|
|
66
|
+
data_received_for_request(request_id)
|
67
|
+
}.flatten.inject(0) { |bytes_sum, n| bytes_sum + n.encoded_data_length }
|
68
|
+
end
|
69
|
+
|
70
|
+
# The number of bytes downloaded for a particular resource type. If the
|
71
|
+
# resource was gzipped during transfer then the uncompressed size is
|
72
|
+
# reported.
|
73
|
+
#
|
74
|
+
# The HTTP headers for the response are NOT included in the byte count.
|
75
|
+
#
|
76
|
+
# Possible resource types: 'Document','Script', 'Image', 'Stylesheet',
|
77
|
+
# 'Other'.
|
78
|
+
#
|
79
|
+
def bytes(resource_type)
|
80
|
+
@events.select {|e|
|
81
|
+
e.is_a?(ResponseReceived) && e.resource_type == resource_type
|
82
|
+
}.map { |e|
|
83
|
+
e.request_id
|
84
|
+
}.map { |request_id|
|
85
|
+
data_received_for_request(request_id)
|
86
|
+
}.flatten.inject(0) { |bytes_sum, n| bytes_sum + n.data_length }
|
87
|
+
end
|
88
|
+
|
89
|
+
# The number of network requests required to load this document
|
90
|
+
#
|
91
|
+
def request_count
|
92
|
+
@events.select {|n|
|
93
|
+
n.is_a?(ResponseReceived)
|
94
|
+
}.size
|
95
|
+
end
|
96
|
+
|
97
|
+
# the number of network requests of a particular resource
|
98
|
+
# type that were required to load this document
|
99
|
+
#
|
100
|
+
# Possible resource types: 'Document', 'Script', 'Image', 'Stylesheet',
|
101
|
+
# 'Other'.
|
102
|
+
#
|
103
|
+
def request_count_by_resource(resource_type)
|
104
|
+
@events.select {|n|
|
105
|
+
n.is_a?(ResponseReceived) && n.resource_type == resource_type
|
106
|
+
}.size
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def data_received_for_request(id)
|
112
|
+
@events.select { |e|
|
113
|
+
e.is_a?(DataReceived) && e.request_id == id
|
114
|
+
}
|
115
|
+
end
|
116
|
+
|
117
|
+
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ChromeDebugger
|
2
|
+
class Notification
|
3
|
+
|
4
|
+
attr_reader :method
|
5
|
+
|
6
|
+
def initialize(point)
|
7
|
+
@params = point['params'] || {}
|
8
|
+
@method = point['method']
|
9
|
+
end
|
10
|
+
|
11
|
+
def resource_type
|
12
|
+
@params['type']
|
13
|
+
end
|
14
|
+
|
15
|
+
def request_id
|
16
|
+
@params['requestId']
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
metadata
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: chrome_debugger
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Justin Morris
|
9
|
+
- James Healy
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2012-08-27 00:00:00.000000000 Z
|
14
|
+
dependencies: []
|
15
|
+
description: Starts a Google Chrome session. Load pages and examine the results.
|
16
|
+
email:
|
17
|
+
- justin.morris@theconversation.edu.au
|
18
|
+
- james.healy@theconversation.edu.au
|
19
|
+
executables: []
|
20
|
+
extensions: []
|
21
|
+
extra_rdoc_files: []
|
22
|
+
files:
|
23
|
+
- examples/basic.rb
|
24
|
+
- examples/theconversation.rb
|
25
|
+
- lib/chrome_debugger/client.rb
|
26
|
+
- lib/chrome_debugger/data_received.rb
|
27
|
+
- lib/chrome_debugger/document.rb
|
28
|
+
- lib/chrome_debugger/dom_content_event_fired.rb
|
29
|
+
- lib/chrome_debugger/load_event_fired.rb
|
30
|
+
- lib/chrome_debugger/notification.rb
|
31
|
+
- lib/chrome_debugger/request_will_be_sent.rb
|
32
|
+
- lib/chrome_debugger/response_received.rb
|
33
|
+
- lib/chrome_debugger.rb
|
34
|
+
- README.md
|
35
|
+
- CHANGELOG
|
36
|
+
homepage: http://github.com/conversation/chrome_debugger
|
37
|
+
licenses: []
|
38
|
+
post_install_message:
|
39
|
+
rdoc_options:
|
40
|
+
- --title
|
41
|
+
- Chrome Debugger
|
42
|
+
- --line-numbers
|
43
|
+
require_paths:
|
44
|
+
- lib
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
46
|
+
none: false
|
47
|
+
requirements:
|
48
|
+
- - ! '>='
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: 1.9.2
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
52
|
+
none: false
|
53
|
+
requirements:
|
54
|
+
- - ! '>='
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: 1.3.2
|
57
|
+
requirements: []
|
58
|
+
rubyforge_project:
|
59
|
+
rubygems_version: 1.8.24
|
60
|
+
signing_key:
|
61
|
+
specification_version: 3
|
62
|
+
summary: Remotely control Google Chrome and extract stats
|
63
|
+
test_files: []
|