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.
- 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
|