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