cuprite 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b3d43410a4b7875e111108b5ef1b53bf07589f833925ea3a9e336d725d99b95
4
+ data.tar.gz: 6cea0c2abdef1b5d8f8b6468f2924dac1cbbe5b51f1a4b6ce74bb980a86278ab
5
+ SHA512:
6
+ metadata.gz: cd77da4241673d610343e552245f5e0421ee6fe8613cc437bd53d0e8bd3d63be8b422a4abf36fcfedf35d79bbed0b7db5fe26250e5b86f737ac4479c6e02281e
7
+ data.tar.gz: cd86413a1b47478e4b0545c28c5ba0ea54a592d889ef0420547d2192bfdac7bece55e9557d88d48cca0720a8d86562dbe281b0988e1234fc55a25d7fc44484c1
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara"
4
+
5
+ Thread.abort_on_exception = true
6
+ Thread.report_on_exception = true
7
+
8
+ module Capybara::Cuprite
9
+ require "cuprite/driver"
10
+ require "cuprite/browser"
11
+ require "cuprite/node"
12
+ require "cuprite/errors"
13
+ require "cuprite/cookie"
14
+
15
+ class << self
16
+ def windows?
17
+ RbConfig::CONFIG["host_os"] =~ /mingw|mswin|cygwin/
18
+ end
19
+
20
+ def mri?
21
+ defined?(RUBY_ENGINE) && RUBY_ENGINE == "ruby"
22
+ end
23
+ end
24
+ end
25
+
26
+ Capybara.register_driver(:cuprite) do |app|
27
+ Capybara::Cuprite::Driver.new(app)
28
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "forwardable"
5
+ require "cuprite/browser/targets"
6
+ require "cuprite/browser/process"
7
+ require "cuprite/browser/client"
8
+ require "cuprite/browser/page"
9
+
10
+ module Capybara::Cuprite
11
+ class Browser
12
+ TIMEOUT = 5
13
+ EXTENSIONS = [
14
+ File.expand_path("browser/javascripts/index.js", __dir__)
15
+ ].freeze
16
+
17
+ extend Forwardable
18
+
19
+ attr_reader :headers
20
+
21
+ def self.start(*args)
22
+ new(*args)
23
+ end
24
+
25
+ delegate subscribe: :@client
26
+ delegate %i(window_handle window_handles switch_to_window open_new_window
27
+ close_window within_window page) => :targets
28
+ delegate %i(visit status_code body all_text property attributes attribute
29
+ value visible? disabled? resize path network_traffic
30
+ clear_network_traffic response_headers refresh click right_click
31
+ double_click hover set click_coordinates drag drag_by select
32
+ trigger scroll_to send_keys evaluate evaluate_on evaluate_async
33
+ execute frame_url frame_title within_frame switch_to_frame
34
+ current_url title go_back go_forward) => :page
35
+
36
+ attr_reader :process, :logger
37
+ attr_writer :timeout
38
+
39
+ def initialize(options = nil)
40
+ @options = Hash(options)
41
+ @logger, @timeout = @options.values_at(:logger, :timeout)
42
+
43
+ if ENV["CUPRITE_DEBUG"]
44
+ STDOUT.sync = true
45
+ @logger = STDOUT
46
+ end
47
+
48
+ start
49
+ end
50
+
51
+ def extensions
52
+ @extensions ||= begin
53
+ exts = @options.fetch(:extensions, [])
54
+ (EXTENSIONS + exts).map { |p| File.read(p) }
55
+ end
56
+ end
57
+
58
+ def timeout
59
+ @timeout || TIMEOUT
60
+ end
61
+
62
+ def source
63
+ raise NotImplementedError
64
+ end
65
+
66
+ def parents(node)
67
+ evaluate_on(node: node, expr: "_cuprite.parents(this)", by_value: false)
68
+ end
69
+
70
+ def find(method, selector)
71
+ find_all(method, selector)
72
+ end
73
+
74
+ def find_within(node, method, selector)
75
+ resolved = page.command("DOM.resolveNode", nodeId: node["nodeId"])
76
+ object_id = resolved.dig("object", "objectId")
77
+ find_all(method, selector, { "objectId" => object_id })
78
+ end
79
+
80
+ def visible_text(node)
81
+ begin
82
+ evaluate_on(node: node, expr: "_cuprite.visibleText(this)")
83
+ rescue BrowserError => e
84
+ # FIXME: ObsoleteNode first arg is node, so it should be in node class
85
+ if e.message == "No node with given id found"
86
+ raise ObsoleteNode.new(self, e.response)
87
+ end
88
+
89
+ raise
90
+ end
91
+ end
92
+
93
+ def delete_text(node)
94
+ raise NotImplementedError
95
+ end
96
+
97
+ def select_file(node, value)
98
+ raise NotImplementedError
99
+ end
100
+
101
+ def render(path, _options = {})
102
+ # check_render_options!(options)
103
+ # options[:full] = !!options[:full]
104
+ data = Base64.decode64(render_base64)
105
+ File.open(path.to_s, "wb") { |f| f.write(data) }
106
+ end
107
+
108
+ def render_base64(format = "png", _options = {})
109
+ # check_render_options!(options)
110
+ # options[:full] = !!options[:full]
111
+ page.command("Page.captureScreenshot", format: format)["data"]
112
+ end
113
+
114
+ def set_zoom_factor(zoom_factor)
115
+ raise NotImplementedError
116
+ end
117
+
118
+ def set_paper_size(size)
119
+ raise NotImplementedError
120
+ end
121
+
122
+ def set_proxy(ip, port, type, user, password)
123
+ raise NotImplementedError
124
+ end
125
+
126
+ def headers=(headers)
127
+ @headers = {}
128
+ add_headers(headers)
129
+ end
130
+
131
+ def add_headers(headers, permanent: true)
132
+ if headers["Referer"]
133
+ page.referrer = headers["Referer"]
134
+ headers.delete("Referer") unless permanent
135
+ end
136
+
137
+ @headers.merge!(headers)
138
+ user_agent = @headers["User-Agent"]
139
+ accept_language = @headers["Accept-Language"]
140
+
141
+ set_overrides(user_agent: user_agent, accept_language: accept_language)
142
+ page.command("Network.setExtraHTTPHeaders", headers: @headers)
143
+ end
144
+
145
+ def add_header(header, permanent: true)
146
+ add_headers(header, permanent: permanent)
147
+ end
148
+
149
+ def set_overrides(user_agent: nil, accept_language: nil, platform: nil)
150
+ options = Hash.new
151
+ options[:userAgent] = user_agent if user_agent
152
+ options[:acceptLanguage] = accept_language if accept_language
153
+ options[:platform] if platform
154
+
155
+ page.command("Network.setUserAgentOverride", **options) if !options.empty?
156
+ end
157
+
158
+ def cookies
159
+ cookies = page.command("Network.getAllCookies")["cookies"]
160
+ cookies.map { |c| [c["name"], Cookie.new(c)] }.to_h
161
+ end
162
+
163
+ def set_cookie(cookie)
164
+ page.command("Network.setCookie", **cookie)
165
+ end
166
+
167
+ def remove_cookie(options)
168
+ page.command("Network.deleteCookies", **options)
169
+ end
170
+
171
+ def clear_cookies
172
+ page.command("Network.clearBrowserCookies")
173
+ end
174
+
175
+ def set_http_auth(user, password)
176
+ raise NotImplementedError
177
+ end
178
+
179
+ def page_settings=(settings)
180
+ raise NotImplementedError
181
+ end
182
+
183
+ def url_whitelist=(whitelist)
184
+ @url_whitelist = Array(whitelist).map { |p| { urlPattern: p } }
185
+ page.command("Network.setRequestInterception", patterns: @url_whitelist)
186
+ end
187
+
188
+ def url_blacklist=(blacklist)
189
+ # FIXME: We have to change the format and make it compatible with Chrome not PhantomJS
190
+ @url_blacklist = Array(blacklist).map { |p| { urlPattern: p.include?("*") ? p : "*#{p}*" } }
191
+ page.command("Network.setRequestInterception", patterns: @url_blacklist)
192
+ end
193
+
194
+ def clear_memory_cache
195
+ page.command("Network.clearBrowserCache")
196
+ end
197
+
198
+ def accept_confirm
199
+ raise NotImplementedError
200
+ end
201
+
202
+ def dismiss_confirm
203
+ raise NotImplementedError
204
+ end
205
+
206
+ def accept_prompt(response)
207
+ raise NotImplementedError
208
+ end
209
+
210
+ def dismiss_prompt
211
+ raise NotImplementedError
212
+ end
213
+
214
+ def modal_message
215
+ raise NotImplementedError
216
+ end
217
+
218
+ def reset
219
+ @headers = {}
220
+ targets.reset
221
+ end
222
+
223
+ def restart
224
+ quit
225
+ start
226
+ end
227
+
228
+ def quit
229
+ @client.close
230
+ @process.stop
231
+ @client = @process = @targets = nil
232
+ end
233
+
234
+ def crash
235
+ command("Browser.crash")
236
+ end
237
+
238
+ def command(*args)
239
+ id = @client.command(*args)
240
+ @client.wait(id: id)
241
+ rescue DeadBrowser
242
+ restart
243
+ raise
244
+ end
245
+
246
+ def targets
247
+ @targets ||= Targets.new(self)
248
+ end
249
+
250
+ private
251
+
252
+ def start
253
+ @headers = {}
254
+ @process = Process.start(@options)
255
+ @client = Client.new(self, @process.ws_url)
256
+ end
257
+
258
+ def check_render_options!(options)
259
+ return if !options[:full] || !options.key?(:selector)
260
+ warn "Ignoring :selector in #render since :full => true was given at #{caller(1..1).first}"
261
+ options.delete(:selector)
262
+ end
263
+
264
+ def find_all(method, selector, within = nil)
265
+ begin
266
+ elements = if within
267
+ evaluate("_cuprite.find(arguments[0], arguments[1], arguments[2])", method, selector, within)
268
+ else
269
+ evaluate("_cuprite.find(arguments[0], arguments[1])", method, selector)
270
+ end
271
+
272
+ elements.map do |element|
273
+ # nodeType: 3, nodeName: "#text" e.g.
274
+ target_id, node = element.values_at("target_id", "node")
275
+ next if node["nodeType"] != 1
276
+ within ? node : [target_id, node]
277
+ end.compact
278
+ rescue JavaScriptError => e
279
+ if e.class_name == "InvalidSelector"
280
+ raise InvalidSelector.new(e.response, method, selector)
281
+ end
282
+ raise
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require "cuprite/browser/web_socket"
5
+
6
+ module Capybara::Cuprite
7
+ class Browser
8
+ class Client
9
+ class IdError < RuntimeError; end
10
+
11
+ def initialize(browser, ws_url)
12
+ @command_id = 0
13
+ @subscribed = Hash.new { |h, k| h[k] = [] }
14
+ @browser = browser
15
+ @commands = Queue.new
16
+ @ws = WebSocket.new(ws_url, @browser.logger)
17
+
18
+ @thread = Thread.new do
19
+ while message = @ws.messages.pop
20
+ method, params = message.values_at("method", "params")
21
+ if method
22
+ @subscribed[method].each { |b| b.call(params) }
23
+ else
24
+ @commands.push(message)
25
+ end
26
+ end
27
+
28
+ @commands.close
29
+ end
30
+ end
31
+
32
+ def command(method, params = {})
33
+ message = build_message(method, params)
34
+ @ws.send_message(message)
35
+ message[:id]
36
+ end
37
+
38
+ def wait(id:)
39
+ message = Timeout.timeout(@browser.timeout, TimeoutError) { @commands.pop }
40
+ raise DeadBrowser unless message
41
+ raise IdError if message["id"] != id
42
+ error, response = message.values_at("error", "result")
43
+ raise BrowserError.new(error) if error
44
+ response
45
+ rescue IdError
46
+ retry
47
+ end
48
+
49
+ def subscribe(event, &block)
50
+ @subscribed[event] << block
51
+ true
52
+ end
53
+
54
+ def close
55
+ @ws.close
56
+ @thread.kill
57
+ end
58
+
59
+ private
60
+
61
+ def build_message(method, params)
62
+ { method: method, params: params }.merge(id: next_command_id)
63
+ end
64
+
65
+ def next_command_id
66
+ @command_id += 1
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,50 @@
1
+ module Capybara::Cuprite
2
+ class Browser
3
+ module DOM
4
+ def current_url
5
+ evaluate_in(@execution_context_id, "location.href")
6
+ end
7
+
8
+ def title
9
+ evaluate_in(@execution_context_id, "document.title")
10
+ end
11
+
12
+ def body
13
+ evaluate("document.documentElement.outerHTML")
14
+ end
15
+
16
+ def all_text(node)
17
+ evaluate_on(node: node, expr: "this.textContent")
18
+ end
19
+
20
+ def property(node, name)
21
+ evaluate_on(node: node, expr: %Q(this["#{name}"]))
22
+ end
23
+
24
+ def attributes(node)
25
+ value = evaluate_on(node: node, expr: "_cuprite.getAttributes(this)")
26
+ JSON.parse(value)
27
+ end
28
+
29
+ def attribute(node, name)
30
+ evaluate_on(node: node, expr: %Q(_cuprite.getAttribute(this, "#{name}")))
31
+ end
32
+
33
+ def value(node)
34
+ evaluate_on(node: node, expr: "_cuprite.value(this)")
35
+ end
36
+
37
+ def visible?(node)
38
+ evaluate_on(node: node, expr: "_cuprite.isVisible(this)")
39
+ end
40
+
41
+ def disabled?(node)
42
+ evaluate_on(node: node, expr: "_cuprite.isDisabled(this)")
43
+ end
44
+
45
+ def path(node)
46
+ evaluate_on(node: node, expr: "_cuprite.path(this)")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,109 @@
1
+ module Capybara::Cuprite
2
+ class Browser
3
+ module Frame
4
+ def execution_context_id
5
+ @mutex.synchronize do
6
+ if !@frame_stack.empty?
7
+ @frames[@frame_stack.last]["execution_context_id"]
8
+ else
9
+ @execution_context_id
10
+ end
11
+ end
12
+ end
13
+
14
+ def frame_url
15
+ evaluate("window.location.href")
16
+ end
17
+
18
+ def frame_title
19
+ evaluate("document.title")
20
+ end
21
+
22
+ def switch_to_frame(handle)
23
+ case handle
24
+ when Capybara::Node::Base
25
+ @frame_stack << handle.native.node["frameId"]
26
+ inject_extensions
27
+ when :parent
28
+ @frame_stack.pop
29
+ when :top
30
+ @frame_stack = []
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def subscribe_events
37
+ @client.subscribe("Page.frameAttached") do |params|
38
+ @frames[params["frameId"]] = { "parent_id" => params["parentFrameId"] }
39
+ end
40
+
41
+ @client.subscribe("Page.frameStartedLoading") do |params|
42
+ @waiting_frames << params["frameId"]
43
+ @mutex.try_lock
44
+ end
45
+
46
+ @client.subscribe("Page.frameNavigated") do |params|
47
+ id = params["frame"]["id"]
48
+ if frame = @frames[id]
49
+ frame.merge!(params["frame"].slice("name", "url"))
50
+ end
51
+ end
52
+
53
+ @client.subscribe("Page.frameScheduledNavigation") do |params|
54
+ # Trying to lock mutex if frame is the main frame
55
+ @waiting_frames << params["frameId"]
56
+ @mutex.try_lock
57
+ end
58
+
59
+ @client.subscribe("Page.frameStoppedLoading") do |params|
60
+ # `DOM.performSearch` doesn't work without getting #document node first.
61
+ # It returns node with nodeId 1 and nodeType 9 from which descend the
62
+ # tree and we save it in a variable because if we call that again root
63
+ # node will change the id and all subsequent nodes have to change id too.
64
+ # `command` is not allowed in the block as it will deadlock the process.
65
+ if params["frameId"] == @frame_id
66
+ signal if @waiting_frames.empty?
67
+ @client.command("DOM.getDocument", depth: 0)
68
+ end
69
+
70
+ if @waiting_frames.include?(params["frameId"])
71
+ @waiting_frames.delete(params["frameId"])
72
+ signal if @waiting_frames.empty?
73
+ end
74
+ end
75
+
76
+ @client.subscribe("Runtime.executionContextCreated") do |params|
77
+ frame_id = params.dig("context", "auxData", "frameId")
78
+ execution_context_id = params.dig("context", "id")
79
+
80
+ # Remember the very first frame since it's the main one
81
+ @frame_id ||= frame_id
82
+ @execution_context_id ||= execution_context_id
83
+
84
+ if @frames[frame_id]
85
+ @frames[frame_id].merge!("execution_context_id" => execution_context_id)
86
+ else
87
+ @frames[frame_id] = { "execution_context_id" => execution_context_id }
88
+ end
89
+ end
90
+
91
+ @client.subscribe("Runtime.executionContextDestroyed") do |params|
92
+ execution_context_id = params["executionContextId"]
93
+ id, frame = @frames.find { |_, p| p["execution_context_id"] == execution_context_id }
94
+ frame["execution_context_id"] = nil if frame
95
+
96
+ if @execution_context_id == execution_context_id
97
+ @execution_context_id = nil
98
+ end
99
+ end
100
+
101
+ @client.subscribe("Runtime.executionContextsCleared") do
102
+ # If we didn't have time to set context id at the beginning we have
103
+ # to set lock and release it when we set something.
104
+ @execution_context_id = nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end