cuprite 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/capybara/cuprite.rb +28 -0
- data/lib/capybara/cuprite/browser.rb +286 -0
- data/lib/capybara/cuprite/browser/client.rb +70 -0
- data/lib/capybara/cuprite/browser/dom.rb +50 -0
- data/lib/capybara/cuprite/browser/frame.rb +109 -0
- data/lib/capybara/cuprite/browser/input.rb +123 -0
- data/lib/capybara/cuprite/browser/javascripts/index.js +407 -0
- data/lib/capybara/cuprite/browser/page.rb +278 -0
- data/lib/capybara/cuprite/browser/process.rb +167 -0
- data/lib/capybara/cuprite/browser/runtime.rb +194 -0
- data/lib/capybara/cuprite/browser/targets.rb +109 -0
- data/lib/capybara/cuprite/browser/web_socket.rb +60 -0
- data/lib/capybara/cuprite/cookie.rb +47 -0
- data/lib/capybara/cuprite/driver.rb +396 -0
- data/lib/capybara/cuprite/errors.rb +131 -0
- data/lib/capybara/cuprite/network/error.rb +25 -0
- data/lib/capybara/cuprite/network/request.rb +33 -0
- data/lib/capybara/cuprite/network/response.rb +42 -0
- data/lib/capybara/cuprite/node.rb +216 -0
- data/lib/capybara/cuprite/version.rb +7 -0
- metadata +231 -0
@@ -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
|