cuprite 0.2.0

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.
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cuprite/browser/dom"
4
+ require "cuprite/browser/input"
5
+ require "cuprite/browser/runtime"
6
+ require "cuprite/browser/frame"
7
+ require "cuprite/browser/client"
8
+ require "cuprite/network/error"
9
+ require "cuprite/network/request"
10
+ require "cuprite/network/response"
11
+
12
+ # RemoteObjectId is from a JavaScript world, and corresponds to any JavaScript
13
+ # object, including JS wrappers for DOM nodes. There is a way to convert between
14
+ # node ids and remote object ids (DOM.requestNode and DOM.resolveNode).
15
+ #
16
+ # NodeId is used for inspection, when backend tracks the node and sends updates to
17
+ # the frontend. If you somehow got NodeId over protocol, backend should have
18
+ # pushed to the frontend all of it's ancestors up to the Document node via
19
+ # DOM.setChildNodes. After that, frontend is always kept up-to-date about anything
20
+ # happening to the node.
21
+ #
22
+ # BackendNodeId is just a unique identifier for a node. Obtaining it does not send
23
+ # any updates, for example, the node may be destroyed without any notification.
24
+ # This is a way to keep a reference to the Node, when you don't necessarily want
25
+ # to keep track of it. One example would be linking to the node from performance
26
+ # data (e.g. relayout root node). BackendNodeId may be either resolved to
27
+ # inspected node (DOM.pushNodesByBackendIdsToFrontend) or described in more
28
+ # details (DOM.describeNode).
29
+ module Capybara::Cuprite
30
+ class Browser
31
+ class Page
32
+ include Input, DOM, Runtime, Frame
33
+
34
+ attr_accessor :referrer
35
+ attr_reader :target_id, :status_code, :response_headers
36
+
37
+ def initialize(target_id, browser)
38
+ @wait = 0
39
+ @target_id, @browser = target_id, browser
40
+ @mutex, @resource = Mutex.new, ConditionVariable.new
41
+ @network_traffic = []
42
+
43
+ @frames = {}
44
+ @waiting_frames ||= Set.new
45
+ @frame_stack = []
46
+
47
+ begin
48
+ @session_id = @browser.command("Target.attachToTarget", targetId: @target_id)["sessionId"]
49
+ rescue BrowserError => e
50
+ if e.message == "No target with given id found"
51
+ raise NoSuchWindowError
52
+ else
53
+ raise
54
+ end
55
+ end
56
+
57
+ host = @browser.process.host
58
+ port = @browser.process.port
59
+ ws_url = "ws://#{host}:#{port}/devtools/page/#{@target_id}"
60
+ @client = Client.new(browser, ws_url)
61
+
62
+ subscribe_events
63
+ prepare_page
64
+ end
65
+
66
+ def timeout
67
+ @browser.timeout
68
+ end
69
+
70
+ def visit(url)
71
+ @wait = timeout
72
+ options = { url: url }
73
+ options.merge!(referrer: referrer) if referrer
74
+ response = command("Page.navigate", **options)
75
+ if response["errorText"] == "net::ERR_NAME_RESOLUTION_FAILED"
76
+ raise StatusFailError, "url" => url
77
+ end
78
+ response["frameId"]
79
+ end
80
+
81
+ def close
82
+ @browser.command("Target.detachFromTarget", sessionId: @session_id)
83
+ @browser.command("Target.closeTarget", targetId: @target_id)
84
+ close_connection
85
+ end
86
+
87
+ def close_connection
88
+ @client.close
89
+ end
90
+
91
+ def resize(width, height)
92
+ result = @browser.command("Browser.getWindowForTarget", targetId: @target_id)
93
+ @window_id, @bounds = result.values_at("windowId", "bounds")
94
+ @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { width: width, height: height })
95
+ command("Emulation.setDeviceMetricsOverride", width: width, height: height, deviceScaleFactor: 1, mobile: false)
96
+ end
97
+
98
+ def refresh
99
+ @wait = timeout
100
+ command("Page.reload")
101
+ end
102
+
103
+ def network_traffic(type = nil)
104
+ case type
105
+ when "all"
106
+ @network_traffic
107
+ when "blocked"
108
+ @network_traffic # when request blocked
109
+ else
110
+ @network_traffic # when not request blocked
111
+ end
112
+ end
113
+
114
+ def clear_network_traffic
115
+ @network_traffic = []
116
+ end
117
+
118
+ def go_back
119
+ go(-1)
120
+ end
121
+
122
+ def go_forward
123
+ go(1)
124
+ end
125
+
126
+ def command(*args)
127
+ id = nil
128
+
129
+ @mutex.synchronize do
130
+ id = @client.command(*args)
131
+ stop_at = Time.now.to_f + @wait
132
+
133
+ while @wait > 0 && (remain = stop_at - Time.now.to_f) > 0
134
+ @resource.wait(@mutex, remain)
135
+ end
136
+
137
+ @wait = 0
138
+ end
139
+
140
+ response = @client.wait(id: id)
141
+ end
142
+
143
+ private
144
+
145
+ def subscribe_events
146
+ super
147
+
148
+ if @browser.logger
149
+ @client.subscribe("Runtime.consoleAPICalled") do |params|
150
+ params["args"].each { |r| @browser.logger.puts(r["value"]) }
151
+ end
152
+ end
153
+
154
+ @client.subscribe("Page.windowOpen") do
155
+ @browser.targets.refresh
156
+ @mutex.try_lock
157
+ sleep 0.3 # Dirty hack because new window doesn't have events at all
158
+ @mutex.unlock if @mutex.locked? && @mutex.owned?
159
+ end
160
+
161
+ @client.subscribe("Page.navigatedWithinDocument") do
162
+ signal if @waiting_frames.empty?
163
+ end
164
+
165
+ @client.subscribe("Page.domContentEventFired") do |params|
166
+ # `frameStoppedLoading` doesn't occur if status isn't success
167
+ if @status_code != 200
168
+ signal
169
+ @client.command("DOM.getDocument", depth: 0)
170
+ end
171
+ end
172
+
173
+ @client.subscribe("Network.requestWillBeSent") do |params|
174
+ if params["frameId"] == @frame_id
175
+ # Possible types:
176
+ # Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR,
177
+ # Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping,
178
+ # CSPViolationReport, Other
179
+ if params["type"] == "Document"
180
+ @mutex.try_lock
181
+ @request_id = params["requestId"]
182
+ end
183
+ end
184
+
185
+ id, time = params.values_at("requestId", "wallTime")
186
+ params = params["request"].merge("id" => id, "time" => time)
187
+ @network_traffic << Network::Request.new(params)
188
+ end
189
+
190
+ @client.subscribe("Network.responseReceived") do |params|
191
+ if params["requestId"] == @request_id
192
+ @response_headers = params.dig("response", "headers")
193
+ @status_code = params.dig("response", "status")
194
+ end
195
+
196
+ if request = @network_traffic.find { |r| r.id == params["requestId"] }
197
+ params = params["response"].merge("id" => params["requestId"])
198
+ request.response = Network::Response.new(params)
199
+ end
200
+ end
201
+
202
+ @client.subscribe("Network.requestIntercepted") do |params|
203
+ @client.command("Network.continueInterceptedRequest", interceptionId: params["interceptionId"], errorReason: "Aborted")
204
+ end
205
+
206
+ @client.subscribe("Log.entryAdded") do |params|
207
+ source = params.dig("entry", "source")
208
+ level = params.dig("entry", "level")
209
+ if source == "network" && level == "error"
210
+ id = params.dig("entry", "networkRequestId")
211
+ if request = @network_traffic.find { |r| r.id == id }
212
+ request.error = Network::Error.new(params["entry"])
213
+ end
214
+ end
215
+ end
216
+ end
217
+
218
+ def prepare_page
219
+ command("Page.enable")
220
+ command("DOM.enable")
221
+ command("CSS.enable")
222
+ command("Runtime.enable")
223
+ command("Log.enable")
224
+ command("Network.enable")
225
+
226
+ @browser.extensions.each do |extension|
227
+ @client.command("Page.addScriptToEvaluateOnNewDocument", source: extension)
228
+ end
229
+
230
+ inject_extensions
231
+
232
+ response = command("Page.getNavigationHistory")
233
+ if response.dig("entries", 0, "transitionType") != "typed"
234
+ # If we create page by clicking links, submiting forms and so on it
235
+ # opens a new window for which `frameStoppedLoading` event never
236
+ # occurs and thus search for nodes cannot be completed. Here we check
237
+ # the history and if the transitionType for example `link` then
238
+ # content is already loaded and we can try to get the document.
239
+ @client.command("DOM.getDocument", depth: 0)
240
+ end
241
+ end
242
+
243
+ def inject_extensions
244
+ @browser.extensions.each do |extension|
245
+ # https://github.com/GoogleChrome/puppeteer/issues/1443
246
+ # https://github.com/ChromeDevTools/devtools-protocol/issues/77
247
+ # https://github.com/cyrus-and/chrome-remote-interface/issues/319
248
+ # We also evaluate script just in case because
249
+ # `Page.addScriptToEvaluateOnNewDocument` doesn't work in popups.
250
+ @client.command("Runtime.evaluate", expression: extension,
251
+ contextId: execution_context_id,
252
+ returnByValue: true)
253
+ end
254
+ end
255
+
256
+ def signal
257
+ @wait = 0
258
+
259
+ if @mutex.locked? && @mutex.owned?
260
+ @resource.signal
261
+ @mutex.unlock
262
+ else
263
+ @mutex.synchronize { @resource.signal }
264
+ end
265
+ end
266
+
267
+ def go(delta)
268
+ history = command("Page.getNavigationHistory")
269
+ index, entries = history.values_at("currentIndex", "entries")
270
+
271
+ if entry = entries[index + delta]
272
+ @wait = 0.05 # Potential wait because of network event
273
+ command("Page.navigateToHistoryEntry", entryId: entry["id"])
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cliver"
4
+
5
+ module Capybara::Cuprite
6
+ class Browser
7
+ class Process
8
+ KILL_TIMEOUT = 2
9
+
10
+ BROWSER_PATH = ENV.fetch("BROWSER_PATH", "chrome")
11
+ BROWSER_HOST = "127.0.0.1"
12
+ BROWSER_PORT = "0"
13
+
14
+ # Chromium command line options
15
+ # https://peter.sh/experiments/chromium-command-line-switches/
16
+ DEFAULT_OPTIONS = {
17
+ "headless" => nil,
18
+ "disable-gpu" => nil,
19
+ "hide-scrollbars" => nil,
20
+ "mute-audio" => nil,
21
+ # Note: --no-sandbox is not needed if you properly setup a user in the container.
22
+ # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
23
+ # "no-sandbox" => nil,
24
+ "enable-automation" => nil,
25
+ "disable-web-security" => nil,
26
+ }.freeze
27
+
28
+ attr_reader :host, :port, :ws_url, :pid, :options
29
+
30
+ def self.start(*args)
31
+ new(*args).tap(&:start)
32
+ end
33
+
34
+ def self.process_killer(pid)
35
+ proc do
36
+ begin
37
+ if Capybara::Cuprite.windows?
38
+ ::Process.kill("KILL", pid)
39
+ else
40
+ ::Process.kill("TERM", pid)
41
+ start = Time.now
42
+ while ::Process.wait(pid, ::Process::WNOHANG).nil?
43
+ sleep 0.05
44
+ next unless (Time.now - start) > KILL_TIMEOUT
45
+ ::Process.kill("KILL", pid)
46
+ ::Process.wait(pid)
47
+ break
48
+ end
49
+ end
50
+ rescue Errno::ESRCH, Errno::ECHILD
51
+ end
52
+ end
53
+ end
54
+
55
+ def initialize(options)
56
+ @options = options.fetch(:browser, {})
57
+ exe = options[:path] || BROWSER_PATH
58
+ @path = Cliver.detect(exe)
59
+
60
+ unless @path
61
+ message = "Could not find an executable `#{exe}`. Try to make it " \
62
+ "available on the PATH or set environment varible for " \
63
+ "example BROWSER_PATH=\"/Applications/Chromium.app/Contents/MacOS/Chromium\""
64
+ raise Cliver::Dependency::NotFound.new(message)
65
+ end
66
+
67
+ window_size = options.fetch(:window_size, [1024, 768])
68
+ @options = @options.merge("window-size" => window_size.join(","))
69
+
70
+ port = options.fetch(:port, BROWSER_PORT)
71
+ @options = @options.merge("remote-debugging-port" => port)
72
+
73
+ host = options.fetch(:host, BROWSER_HOST)
74
+ @options = @options.merge("remote-debugging-address" => host)
75
+
76
+ @options = DEFAULT_OPTIONS.merge(@options)
77
+ end
78
+
79
+ def start
80
+ read_io, write_io = IO.pipe
81
+ process_options = { in: File::NULL }
82
+ process_options[:pgroup] = true unless Capybara::Cuprite.windows?
83
+ if Capybara::Cuprite.mri?
84
+ process_options[:out] = process_options[:err] = write_io
85
+ end
86
+
87
+ redirect_stdout(write_io) do
88
+ cmd = [@path] + @options.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
89
+ @pid = ::Process.spawn(*cmd, process_options)
90
+ ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
91
+ end
92
+
93
+ parse_ws_url(read_io)
94
+ ensure
95
+ close_io(read_io, write_io)
96
+ end
97
+
98
+ def stop
99
+ return unless @pid
100
+ kill
101
+ ObjectSpace.undefine_finalizer(self)
102
+ end
103
+
104
+ def restart
105
+ stop
106
+ start
107
+ end
108
+
109
+ private
110
+
111
+ def redirect_stdout(write_io)
112
+ if Capybara::Cuprite.mri?
113
+ yield
114
+ else
115
+ begin
116
+ prev = STDOUT.dup
117
+ $stdout = write_io
118
+ STDOUT.reopen(write_io)
119
+ yield
120
+ ensure
121
+ STDOUT.reopen(prev)
122
+ $stdout = STDOUT
123
+ prev.close
124
+ end
125
+ end
126
+ end
127
+
128
+ def kill
129
+ self.class.process_killer(@pid).call
130
+ @pid = nil
131
+ end
132
+
133
+ def parse_ws_url(read_io)
134
+ output = ""
135
+ attempts = 3
136
+ regexp = /DevTools listening on (ws:\/\/.*)/
137
+ loop do
138
+ begin
139
+ output += read_io.read_nonblock(512)
140
+ rescue IO::WaitReadable
141
+ attempts -= 1
142
+ break if attempts <= 0
143
+ IO.select([read_io], nil, nil, 1)
144
+ retry
145
+ end
146
+
147
+ if output.match?(regexp)
148
+ @ws_url = Addressable::URI.parse(output.match(regexp)[1])
149
+ @host = @ws_url.host
150
+ @port = @ws_url.port
151
+ break
152
+ end
153
+ end
154
+ end
155
+
156
+ def close_io(*ios)
157
+ ios.each do |io|
158
+ begin
159
+ io.close unless io.closed?
160
+ rescue IOError
161
+ raise unless RUBY_ENGINE == 'jruby'
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Cuprite
4
+ class Browser
5
+ module Runtime
6
+ EXECUTE_OPTIONS = {
7
+ returnByValue: true,
8
+ functionDeclaration: %Q(function() { %s })
9
+ }.freeze
10
+ DEFAULT_OPTIONS = {
11
+ functionDeclaration: %Q(function() { return %s })
12
+ }.freeze
13
+ EVALUATE_ASYNC_OPTIONS = {
14
+ awaitPromise: true,
15
+ functionDeclaration: %Q(
16
+ function() {
17
+ return new Promise((__resolve, __reject) => {
18
+ try {
19
+ arguments[arguments.length] = r => __resolve(r);
20
+ arguments.length = arguments.length + 1;
21
+ setTimeout(() => __reject(new TimedOutPromise), %s);
22
+ %s
23
+ } catch(error) {
24
+ __reject(error);
25
+ }
26
+ });
27
+ }
28
+ )
29
+ }.freeze
30
+
31
+ def evaluate(expr, *args)
32
+ response = call(expr, nil, nil, *args)
33
+ handle(response)
34
+ end
35
+
36
+ def evaluate_in(context_id, expr)
37
+ response = call(expr, nil, { executionContextId: context_id })
38
+ handle(response)
39
+ end
40
+
41
+ def evaluate_on(node:, expr:, by_value: true, wait: 0)
42
+ object_id = command("DOM.resolveNode", nodeId: node["nodeId"]).dig("object", "objectId")
43
+ options = DEFAULT_OPTIONS.merge(objectId: object_id)
44
+ options[:functionDeclaration] = options[:functionDeclaration] % expr
45
+ options.merge!(returnByValue: by_value)
46
+
47
+ @wait = wait if wait > 0
48
+
49
+ response = command("Runtime.callFunctionOn", **options)
50
+ .dig("result").tap { |r| handle_error(r) }
51
+
52
+ by_value ? response.dig("value") : handle(response)
53
+ end
54
+
55
+ def evaluate_async(expr, wait_time, *args)
56
+ response = call(expr, wait_time * 1000, EVALUATE_ASYNC_OPTIONS, *args)
57
+ handle(response)
58
+ end
59
+
60
+ def execute(expr, *args)
61
+ call(expr, nil, EXECUTE_OPTIONS, *args)
62
+ true
63
+ end
64
+
65
+ private
66
+
67
+ def call(expr, wait_time, options = nil, *args)
68
+ options ||= {}
69
+ args = prepare_args(args)
70
+
71
+ options = DEFAULT_OPTIONS.merge(options)
72
+ expr = [wait_time, expr] if wait_time
73
+ options[:functionDeclaration] = options[:functionDeclaration] % expr
74
+ options = options.merge(arguments: args)
75
+ unless options[:executionContextId]
76
+ options = options.merge(executionContextId: execution_context_id)
77
+ end
78
+
79
+ command("Runtime.callFunctionOn", **options)
80
+ .dig("result").tap { |r| handle_error(r) }
81
+ end
82
+
83
+ # FIXME: We should have a central place to handle all type of errors
84
+ def handle_error(result)
85
+ return if result["subtype"] != "error"
86
+
87
+ case result["className"]
88
+ when "TimedOutPromise"
89
+ raise ScriptTimeoutError
90
+ when "MouseEventFailed"
91
+ raise MouseEventFailed.new(result["description"])
92
+ else
93
+ raise JavaScriptError.new(result)
94
+ end
95
+ end
96
+
97
+ def prepare_args(args)
98
+ args.map do |arg|
99
+ if arg.is_a?(Node)
100
+ node_id = arg.native.node["nodeId"]
101
+ resolved = command("DOM.resolveNode", nodeId: node_id)
102
+ { objectId: resolved["object"]["objectId"] }
103
+ elsif arg.is_a?(Hash) && arg["objectId"]
104
+ { objectId: arg["objectId"] }
105
+ else
106
+ { value: arg }
107
+ end
108
+ end
109
+ end
110
+
111
+ def handle(response, cleanup = true)
112
+ case response["type"]
113
+ when "boolean", "number", "string"
114
+ response["value"]
115
+ when "undefined"
116
+ nil
117
+ when "function"
118
+ {}
119
+ when "object"
120
+ case response["subtype"]
121
+ when "node"
122
+ node_id = command("DOM.requestNode", objectId: response["objectId"])["nodeId"]
123
+ node = command("DOM.describeNode", nodeId: node_id)["node"]
124
+ { "target_id" => target_id, "node" => node.merge("nodeId" => node_id) }
125
+ when "array"
126
+ reduce_properties(response["objectId"], Array.new) do |memo, key, value|
127
+ next(memo) unless (Integer(key) rescue nil)
128
+ value = value["objectId"] ? handle(value, false) : value["value"]
129
+ memo.insert(key.to_i, value)
130
+ end
131
+ when "date"
132
+ response["description"]
133
+ when "null"
134
+ nil
135
+ else
136
+ reduce_properties(response["objectId"], Hash.new) do |memo, key, value|
137
+ value = value["objectId"] ? handle(value, false) : value["value"]
138
+ memo.merge(key => value)
139
+ end
140
+ end
141
+ end
142
+ ensure
143
+ clean if cleanup
144
+ end
145
+
146
+ def reduce_properties(object_id, object)
147
+ if visited?(object_id)
148
+ "(cyclic structure)"
149
+ else
150
+ properties(object_id).reduce(object) do |memo, prop|
151
+ next(memo) unless prop["enumerable"]
152
+ yield(memo, prop["name"], prop["value"])
153
+ end
154
+ end
155
+ end
156
+
157
+ def properties(object_id)
158
+ command("Runtime.getProperties", objectId: object_id)["result"]
159
+ end
160
+
161
+ # Every `Runtime.getProperties` call on the same object returns new object
162
+ # id each time {"objectId":"{\"injectedScriptId\":1,\"id\":1}"} and it's
163
+ # impossible to check that two objects are actually equal. This workaround
164
+ # does equality check only in JS runtime. `_cuprite` can be inavailable here
165
+ # if page is about:blank for example.
166
+ def visited?(object_id)
167
+ expr = %Q(
168
+ let object = arguments[0];
169
+ let callback = arguments[1];
170
+
171
+ if (window._cupriteVisitedObjects === undefined) {
172
+ window._cupriteVisitedObjects = [];
173
+ }
174
+
175
+ let visited = window._cupriteVisitedObjects;
176
+ if (visited.some(o => o === object)) {
177
+ callback(true);
178
+ } else {
179
+ visited.push(object);
180
+ callback(false);
181
+ }
182
+ )
183
+
184
+ # FIXME: Is there a way we can use wait_time here?
185
+ response = call(expr, 5, EVALUATE_ASYNC_OPTIONS, { "objectId" => object_id })
186
+ handle(response, false)
187
+ end
188
+
189
+ def clean
190
+ execute("delete window._cupriteVisitedObjects")
191
+ end
192
+ end
193
+ end
194
+ end