cuprite 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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