capybara-chrome 0.1.22

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,204 @@
1
+ module Capybara::Chrome
2
+
3
+ # Chrome Remote Debugging Protocol (RDP) Client
4
+ class RDPClient
5
+ require "open-uri"
6
+
7
+ include Debug
8
+
9
+ attr_reader :response_events, :response_messages, :loader_ids, :listen_mutex, :handler_calls, :ws, :handlers, :browser
10
+
11
+ def initialize(chrome_host:, chrome_port:, browser:)
12
+ @chrome_host = chrome_host
13
+ @chrome_port = chrome_port
14
+ @browser = browser
15
+ @last_id = 0
16
+ @ws = nil
17
+ @ws_thread = nil
18
+ @ws_mutex = Mutex.new
19
+ @handlers = Hash.new { |hash, key| hash[key] = [] }
20
+ @listen_mutex = Mutex.new
21
+ @response_messages = {}
22
+ @response_events = []
23
+ @read_mutex = Mutex.new
24
+ @handler_mutex = Mutex.new
25
+ @loader_ids = []
26
+ @handler_calls = []
27
+ end
28
+
29
+ def reset
30
+ @calling_handlers = false
31
+ response_messages.clear
32
+ response_events.clear
33
+ loader_ids.clear
34
+ end
35
+
36
+ def generate_unique_id
37
+ @last_id += 1
38
+ end
39
+
40
+ def send_cmd!(command, params={})
41
+ debug command, params
42
+ msg_id = generate_unique_id
43
+ send_msg({method: command, params: params, id: msg_id}.to_json)
44
+ msg_id
45
+ end
46
+
47
+ # Errno::EPIPE
48
+ def send_cmd(command, params={})
49
+ msg_id = send_cmd!(command, params)
50
+
51
+ debug "waiting #{command} #{msg_id}"
52
+ msg = nil
53
+ begin
54
+ until msg = @response_messages[msg_id]
55
+ read_and_process(1)
56
+ end
57
+ @response_messages.delete msg_id
58
+ rescue Timeout::Error
59
+ puts "TimeoutError #{command} #{params.inspect} #{msg_id}"
60
+ send_cmd! "Runtime.terminateExecution"
61
+ puts "Recovering"
62
+ recover_chrome_crash
63
+ raise ResponseTimeoutError
64
+ rescue WebSocketError => e
65
+ puts "send_cmd received websocket error #{e.inspect}"
66
+ recover_chrome_crash
67
+ raise e
68
+ rescue Errno::EPIPE, EOFError => e
69
+ puts "send_cmd received EPIPE or EOF error #{e.inspect}"
70
+ recover_chrome_crash
71
+ raise e
72
+ rescue => e
73
+ puts "send_cmd caught error #{e.inspect} when issuing #{command} #{params.inspect}"
74
+ puts caller
75
+ raise e
76
+ end
77
+ return msg["result"]
78
+ end
79
+
80
+ def recover_chrome_crash
81
+ $stderr.puts "Chrome Crashed... #{Capybara::Chrome.wants_to_quit.inspect} #{::RSpec.wants_to_quit.inspect}" unless Capybara::Chrome.wants_to_quit
82
+ browser.restart_chrome
83
+ browser.start_remote
84
+ end
85
+
86
+ def send_msg(msg)
87
+ retries ||= 0
88
+ ws.send_msg(msg)
89
+ rescue Errno::EPIPE, EOFError => exception
90
+ retries += 1
91
+ recover_chrome_crash
92
+ if retries < 5 && !::Capybara::Chrome.wants_to_quit
93
+ retry
94
+ else
95
+ raise exception
96
+ end
97
+ end
98
+
99
+ def on(event_name, &block)
100
+ handlers[event_name] << block
101
+ end
102
+
103
+ def wait_for(event_name, timeout=Capybara.default_max_wait_time)
104
+ @response_events.clear
105
+ msg = nil
106
+ loop do
107
+ msgs = @response_events.select {|v| v["method"] == event_name}
108
+ if msgs.any?
109
+ if block_given?
110
+ do_return = msgs.detect do |m|
111
+ val = yield m["params"]
112
+ end
113
+ if do_return
114
+ msg = do_return.dup
115
+ @response_events.delete do_return
116
+ break
117
+ else
118
+ read_and_process(1)
119
+ next
120
+ end
121
+ else
122
+ msg = msgs.first.dup
123
+ msgs.each {|m| @response_events.delete m}
124
+ break
125
+ end
126
+ else
127
+ end
128
+ read_and_process(1)
129
+ end
130
+ return msg && msg["params"]
131
+ rescue Timeout::Error
132
+ nil
133
+ end
134
+
135
+ def process_messages
136
+ n = 0
137
+ while @ws.messages.any? do
138
+ n += 1
139
+ msg_raw = @ws.messages.shift
140
+ if msg_raw
141
+ msg = JSON.parse(msg_raw)
142
+ if msg["method"]
143
+ hs = handlers[msg["method"]]
144
+ if hs.any?
145
+ @handler_calls << [msg["method"], msg["params"]]
146
+ end
147
+ @response_events << msg
148
+ else
149
+ @response_messages[msg["id"]] = msg
150
+ if msg["exceptionDetails"]
151
+ puts JSException.new(val["exceptionDetails"]["exception"].inspect)
152
+ end
153
+ end
154
+ else
155
+ p ["no msg_raw", msg_raw]
156
+ end
157
+ end
158
+ n
159
+ end
160
+
161
+ def read_and_process(timeout=0)
162
+ return unless Thread.current == Thread.main
163
+ ready = select [@ws.socket.io], [], [], timeout
164
+ if ready
165
+ @ws.parse_input
166
+ process_messages
167
+ end
168
+ if !@calling_handlers
169
+ @calling_handlers = true
170
+ while obj = @handler_calls.shift do
171
+ handlers[obj[0]].each {|h| h.call obj[1]}
172
+ end
173
+ @calling_handlers = false
174
+ end
175
+ end
176
+
177
+ def discover_ws_url
178
+ response = open("http://#{@chrome_host}:#{@chrome_port}/json")
179
+ data = JSON.parse(response.read)
180
+ first_page = data.detect {|e| e["type"] == "page"}
181
+ @ws_url = first_page["webSocketDebuggerUrl"]
182
+ end
183
+
184
+ def start
185
+ browser.wait_for_chrome
186
+ browser.with_retry do
187
+ discover_ws_url
188
+ end
189
+ @ws = RDPWebSocketClient.new @ws_url
190
+ send_cmd! "Network.enable"
191
+ send_cmd! "Network.clearBrowserCookies"
192
+ send_cmd! "Page.enable"
193
+ send_cmd! "DOM.enable"
194
+ send_cmd! "CSS.enable"
195
+ send_cmd! "Page.setDownloadBehavior", behavior: "allow", downloadPath: Capybara::Chrome.configuration.download_path
196
+ helper_js = File.expand_path(File.join("..", "..", "chrome_remote_helper.js"), File.dirname(__FILE__))
197
+ send_cmd! "Page.addScriptToEvaluateOnNewDocument", source: File.read(helper_js)
198
+
199
+ Thread.abort_on_exception = true
200
+ return
201
+ end
202
+ end
203
+
204
+ end
@@ -0,0 +1,29 @@
1
+ module Capybara::Chrome
2
+ class RDPSocket
3
+ READ_LEN = 4096
4
+ attr_reader :url, :io
5
+
6
+ def initialize(url)
7
+ uri = URI.parse(url)
8
+ @url = uri.to_s
9
+ @io = TCPSocket.new(uri.host, uri.port)
10
+ end
11
+
12
+ def write(data)
13
+ io.sendmsg data
14
+ end
15
+
16
+ def read
17
+ buf = ""
18
+ loop do
19
+ buf << io.recv_nonblock(READ_LEN)
20
+ end
21
+ rescue IO::EAGAINWaitReadable
22
+ if buf.size == 0
23
+ puts "buf is #{buf.size}"
24
+ puts caller[0..10]
25
+ end
26
+ buf
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ module Capybara::Chrome
2
+ class RDPWebSocketClient
3
+ attr_reader :socket, :driver, :messages, :status
4
+
5
+ def initialize(url)
6
+ @socket = RDPSocket.new(url)
7
+ @driver = ::WebSocket::Driver.client(socket)
8
+
9
+ @messages = []
10
+ @status = :closed
11
+
12
+ setup_driver
13
+ start_driver
14
+ end
15
+
16
+ def send_msg(msg)
17
+ driver.text msg
18
+ end
19
+
20
+ def parse_input
21
+ @driver.parse(@socket.read)
22
+ end
23
+
24
+ private
25
+
26
+ def setup_driver
27
+ driver.on(:message) do |e|
28
+ messages << e.data
29
+ end
30
+
31
+ driver.on(:error) do |e|
32
+ raise WebSocketError.new e.message
33
+ end
34
+
35
+ driver.on(:close) do |e|
36
+ raise "closed"
37
+ @status = :closed
38
+ end
39
+
40
+ driver.on(:open) do |e|
41
+ @status = :open
42
+ end
43
+ end
44
+
45
+ def start_driver
46
+ driver.start
47
+ select [socket.io]
48
+ parse_input until status == :open
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ module Capybara::Chrome
2
+ module DumbTimeout
3
+ class Error < StandardError
4
+ end
5
+
6
+ def self.timeout(n)
7
+ start_at = Time.now
8
+ loop do
9
+ raise Error.new("waited #{n}s") if (Time.now - start_at).to_i >= n
10
+ yield
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,109 @@
1
+ module Capybara::Chrome
2
+ module Service
3
+
4
+ # "--disable-gpu",
5
+ # '--js-flags="--max-old-space-size=500"',
6
+ CHROME_ARGS = [
7
+ "--headless",
8
+ "--enable-automation",
9
+ "--crash-dumps-dir=/tmp",
10
+ "--disable-background-networking",
11
+ "--disable-background-timer-throttling",
12
+ "--disable-breakpad",
13
+ "--disable-client-side-phishing-detection",
14
+ "--disable-default-apps",
15
+ "--disable-dev-shm-usage",
16
+ "--disable-extensions",
17
+ "--disable-features=site-per-process",
18
+ "--disable-hang-monitor",
19
+ "--disable-popup-blocking",
20
+ "--disable-prompt-on-repost",
21
+ "--disable-sync",
22
+ "--disable-translate",
23
+ "--metrics-recording-only",
24
+ "--no-first-run",
25
+ "--no-pings",
26
+ "--safebrowsing-disable-auto-update",
27
+ "--hide-scrollbars",
28
+ "--mute-audio",
29
+ ]
30
+
31
+ def chrome_pid
32
+ @chrome_pid
33
+ end
34
+
35
+ def start_chrome
36
+ return if chrome_running?
37
+ debug "Starting Chrome", chrome_path, chrome_args
38
+ @chrome_pid = Process.spawn chrome_path, *chrome_args, :out=>"/dev/null"
39
+ at_exit { stop_chrome }
40
+ end
41
+
42
+ def stop_chrome
43
+ Process.kill "TERM", chrome_pid rescue nil
44
+ end
45
+
46
+ def restart_chrome
47
+ stop_chrome
48
+ if chrome_running?
49
+ @chrome_port = find_available_port(@chrome_host)
50
+ end
51
+ start_chrome
52
+ end
53
+
54
+ def wait_for_chrome
55
+ running = false
56
+ while !running
57
+ running = chrome_running?
58
+ sleep 0.02
59
+ end
60
+ end
61
+
62
+ def chrome_running?
63
+ socket = TCPSocket.new(@chrome_host, @chrome_port) rescue false
64
+ socket.close if socket
65
+ !!socket
66
+ end
67
+
68
+ def chrome_path
69
+ case os
70
+ when :macosx
71
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
72
+ when :linux
73
+ # /opt/google/chrome/chrome
74
+ "google-chrome-stable"
75
+ end
76
+ end
77
+
78
+ def chrome_args
79
+ CHROME_ARGS + ["--remote-debugging-port=#{@chrome_port}"]
80
+ end
81
+
82
+ def os
83
+ @os ||= (
84
+ host_os = RbConfig::CONFIG['host_os']
85
+ case host_os
86
+ when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
87
+ :windows
88
+ when /darwin|mac os/
89
+ :macosx
90
+ when /linux/
91
+ :linux
92
+ when /solaris|bsd/
93
+ :unix
94
+ else
95
+ raise Error::WebDriverError, "unknown os: #{host_os.inspect}"
96
+ end
97
+ )
98
+ end
99
+
100
+ def find_available_port(host)
101
+ sleep rand * 0.7 # slight delay to account for concurrent browsers
102
+ server = TCPServer.new(host, 0)
103
+ server.addr[1]
104
+ ensure
105
+ server.close if server
106
+ end
107
+
108
+ end
109
+ end
@@ -0,0 +1,5 @@
1
+ module Capybara
2
+ module Chrome
3
+ VERSION = "0.1.22"
4
+ end
5
+ end
@@ -0,0 +1,340 @@
1
+ window.ChromeRemotePageLoaded = document.readyState == "complete"
2
+ window.ChromeRemoteHelper = {
3
+ DOMContentLoaded: document.readyState == "interactive" || document.readyState == "complete",
4
+ windowLoaded: document.readyState == "complete",
5
+ windowUnloading: false,
6
+ nextIndex: 0,
7
+ nodes: {},
8
+ nodeClicks: {},
9
+ TEXT_TYPES: ["date", "email", "number", "password", "search", "tel", "text", "textarea", "url"],
10
+ windowWaitPromise: null,
11
+
12
+ registerNode: function(node) {
13
+ this.nextIndex++;
14
+ this.nodes[this.nextIndex] = node;
15
+ return this.nextIndex;
16
+ },
17
+
18
+ waitPromise: function(truthFn, delay) {
19
+ var ms = 0;
20
+ var intId;
21
+ var p = new Promise(function(resolve, reject) {
22
+ var truthy = truthFn();
23
+ if (truthy) {
24
+ resolve(truthy);
25
+ } else if (this.windowUnloading) {
26
+ if (intId) {
27
+ window.clearInterval(intId);
28
+ }
29
+ resolve(false);
30
+ } else {
31
+ intId = window.setInterval(function() {
32
+ truthy = truthFn(intId);
33
+ ms += delay;
34
+ if (truthy || ms > 500) {
35
+ window.clearInterval(intId);
36
+ resolve(truthy);
37
+ } else if (this.windowUnloading) {
38
+ resolve(false);
39
+ } else {
40
+ console.log("not truthy", truthy, this);
41
+ }
42
+ }.bind(this), delay);
43
+ }
44
+ }.bind(this));
45
+ return p;
46
+ },
47
+
48
+ waitUnload: function() {
49
+ var ms = 0;
50
+ var delay = 1;
51
+ var intId;
52
+ var p = new Promise(function(resolve, reject) {
53
+ if (this.windowUnloading) {
54
+ resolve(true);
55
+ }
56
+ var fn = function() {
57
+ ms += delay;
58
+ if (ms >= 100) {
59
+ //clearInterval(intId);
60
+ return true;
61
+ } else {
62
+ if (this.windowUnloading) {
63
+ // clearInterval(intId);
64
+ return true;
65
+ } else {
66
+ return false;
67
+ }
68
+ }
69
+ }.bind(this);
70
+ var redo = function() {
71
+ if (fn()) {
72
+ return resolve(true);
73
+ } else {
74
+ window.setTimeout(redo, delay);
75
+ }
76
+ }.bind(this);
77
+ redo();
78
+
79
+ // intId = window.setInterval(, delay);
80
+ }.bind(this));
81
+ return p;
82
+ },
83
+
84
+ waitWindowLoaded: function() {
85
+ var ms = 0;
86
+ var delay = 0;
87
+ this.windowWaitPromise = this.waitPromise(function() {
88
+ return this.windowLoaded;
89
+ }.bind(this), delay);
90
+ return this.windowWaitPromise;
91
+ },
92
+
93
+ waitDOMContentLoaded: function() {
94
+ return this.waitPromise(function() {
95
+ return this.DOMContentLoaded;
96
+ }.bind(this), 5);
97
+ },
98
+
99
+ findCss: function(query) {
100
+ return this.findCssRelativeTo(document, query);
101
+ },
102
+
103
+ findCssWithin: function (index, query) {
104
+ return this.findCssRelativeTo(this.getNode(index), query);
105
+ },
106
+
107
+ findCssRelativeTo: function(reference, query) {
108
+ return this.waitDOMContentLoaded().then(function() {
109
+ var results = [];
110
+ var list = reference.querySelectorAll(query);
111
+ for (i = 0; i < list.length; i++) {
112
+ results.push(this.registerNode(list[i]));
113
+ }
114
+ return results.join(",");
115
+ }.bind(this));
116
+ },
117
+
118
+ findXPath: function(query) {
119
+ return this.findXpathRelativeTo(document, query);
120
+ },
121
+
122
+ findXPathWithin: function(index, query) {
123
+ return this.findXpathRelativeTo(this.getNode(index), query);
124
+ },
125
+
126
+ findXpathRelativeTo: function(reference, query) {
127
+ return this.waitDOMContentLoaded().then(function() {
128
+ var iterator = document.evaluate(query, reference, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
129
+ var node;
130
+ var results = [];
131
+ while (node = iterator.iterateNext()) {
132
+ results.push(this.registerNode(node));
133
+ }
134
+ return results.join(",");
135
+ }.bind(this));
136
+ },
137
+
138
+ getXPathNode: function(node, path) {
139
+ path = path || [];
140
+ if (node.parentNode) {
141
+ path = this.getXPathNode(node.parentNode, path);
142
+ }
143
+
144
+ var first = node;
145
+ while (first.previousSibling)
146
+ first = first.previousSibling;
147
+
148
+ var count = 0;
149
+ var index = 0;
150
+ var iter = first;
151
+ while (iter) {
152
+ if (iter.nodeType == 1 && iter.nodeName == node.nodeName)
153
+ count++;
154
+ if (iter.isSameNode(node))
155
+ index = count;
156
+ iter = iter.nextSibling;
157
+ continue;
158
+ }
159
+
160
+ if (node.nodeType == 1)
161
+ path.push(node.nodeName.toLowerCase() + (node.id ? "[@id='"+node.id+"']" : count > 1 ? "["+index+"]" : ''));
162
+
163
+ return path;
164
+ },
165
+
166
+ nodePathForNode: function(index) {
167
+ return this.pathForNode(this.getNode(index));
168
+ },
169
+
170
+ pathForNode: function(node) {
171
+ return "/" + this.getXPathNode(node).join("/");
172
+ },
173
+
174
+ getNode: function(index) {
175
+ var node = this.nodes[index];
176
+ if (!node) {
177
+ throw new NodeNotFoundError("No node found with id:"+index+". Registered nodes:"+Object.keys(this.nodes).length);
178
+ }
179
+ return node;
180
+ },
181
+
182
+ onSelf: function(index, script) {
183
+ var node = this.getNode(index);
184
+ // console.log("onSelf " + index + " " + node.tagName + " " + (node.parentElement && node.parentElement.tagName) + " " + script);
185
+ var fn = Function("'use strict';"+script).bind(node)
186
+ var pp = new Promise(function(resolve) { resolve(fn()); });
187
+ return pp;
188
+ },
189
+
190
+ // args should be an array
191
+ onSelfValue: function(index, meth, args) {
192
+ var node = this.getNode(index);
193
+ var val = node[meth];
194
+ if (typeof val === "function") {
195
+ val.apply(node, args);
196
+ } else {
197
+ return val;
198
+ }
199
+ },
200
+
201
+ dispatchEvent: function(node, eventName) {
202
+ var eventObject;
203
+ if (eventName == "click") {
204
+ eventObject = new MouseEvent("click", {bubbles: true, cancelable: true});
205
+ } else {
206
+ eventObject = document.createEvent("HTMLEvents");
207
+ eventObject.initEvent(eventName, true, true);
208
+ }
209
+ return node.dispatchEvent(eventObject);
210
+ },
211
+
212
+ nodeSetType: function(index) {
213
+ var node = this.getNode(index);
214
+ return (node.type || node.tagName).toLowerCase();
215
+ },
216
+
217
+ nodeSet: function(index, value, type) {
218
+ var node = this.getNode(index);
219
+ if (type == "checkbox" || type == "radio") {
220
+ if (value == "true" && !node.checked) {
221
+ return node.click();
222
+ } else if (value == "false" && node.checked) {
223
+ return node.click();
224
+ }
225
+ } else {
226
+ return node.value = value;
227
+ }
228
+ },
229
+
230
+ nodeVisible: function(index) {
231
+ return this.visible(this.getNode(index));
232
+ },
233
+
234
+ visible: function(node) {
235
+ var styles = node.ownerDocument.defaultView.getComputedStyle(node);
236
+ if (styles["visibility"] == "hidden" || styles["display"] == "none" || styles["opacity"] == 0) {
237
+ return false;
238
+ }
239
+ while (node = node.parentElement) {
240
+ styles = node.ownerDocument.defaultView.getComputedStyle(node);
241
+ if (styles["display"] == "none" || styles["opacity"] == 0) {
242
+ return false;
243
+ }
244
+ }
245
+ return true;
246
+ },
247
+
248
+ nodeText: function(index) {
249
+ return this.text(this.getNode(index));
250
+ },
251
+
252
+ text: function(node) {
253
+ var type = node instanceof HTMLFormElement ? 'form' : (node.type || node.tagName).toLowerCase();
254
+ if (type == "textarea") {
255
+ return node.innerHTML;
256
+ } else {
257
+ var visible_text = node.innerText;
258
+ return typeof visible_text === "string" ? visible_text : node.textContent;
259
+ }
260
+ },
261
+
262
+ nodeIsNodeAtPosition: function(index, pos) {
263
+ return this.isNodeAtPosition(this.getNode(index), pos);
264
+ },
265
+
266
+ isNodeAtPosition: function(node, pos) {
267
+ var nodeAtPosition =
268
+ document.elementFromPoint(pos.relativeX, pos.relativeY);
269
+ var overlappingPath;
270
+
271
+
272
+ if (nodeAtPosition) {
273
+ // console.log("is node at position" + nodeAtPosition.tagName)
274
+ overlappingPath = this.pathForNode(nodeAtPosition)
275
+ }
276
+
277
+ if (!this.isNodeOrChildAtPosition(node, pos, nodeAtPosition)) {
278
+ // console.log("Would throw " + overlappingPath + " " + this.pathForNode(node))
279
+ return false;
280
+ }
281
+
282
+ return true;
283
+ },
284
+
285
+ isNodeOrChildAtPosition: function(expectedNode, pos, currentNode) {
286
+ if (currentNode == expectedNode) {
287
+ return true;
288
+ } else if (currentNode) {
289
+ return this.isNodeOrChildAtPosition(
290
+ expectedNode,
291
+ pos,
292
+ currentNode.parentNode
293
+ );
294
+ } else {
295
+ return false;
296
+ }
297
+ },
298
+
299
+ nodeGetDimensions: function(index) {
300
+ return this.getDimensions(this.getNode(index));
301
+ },
302
+
303
+ getDimensions: function(node) {
304
+ return JSON.stringify(node.getBoundingClientRect());
305
+ },
306
+
307
+ // don't attach state to the node because the node can go away after click
308
+ attachClickListener: function(index) {
309
+ var node = this.getNode(index);
310
+ this.nodeClicks[index] = false;
311
+ var fn = function() {
312
+ this.nodeClicks[index] = true;
313
+ node.removeEventListener("click", fn);
314
+ }.bind(this);
315
+ node.addEventListener("click", fn);
316
+ },
317
+
318
+ nodeVerifyClicked: function(index) {
319
+ return this.nodeClicks[index];
320
+ }
321
+ }
322
+
323
+ document.addEventListener("DOMContentLoaded", function() {
324
+ ChromeRemoteHelper.DOMContentLoaded = true;
325
+ });
326
+
327
+ window.addEventListener("load", function() {
328
+ ChromeRemoteHelper.windowLoaded = true;
329
+ ChromeRemotePageLoaded = true;
330
+ });
331
+
332
+ window.addEventListener("beforeunload", function() {
333
+ ChromeRemoteHelper.windowUnloading = true;
334
+ ChromeRemoteHelper.windowLoaded = true;
335
+ });
336
+
337
+ window.addEventListener("unload", function() {
338
+ });
339
+
340
+ class NodeNotFoundError extends Error{}