chrome_debugger 0.0.1
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.
- 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: []
|