cuprite 0.6.0 → 0.7.1

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.
@@ -1,223 +0,0 @@
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
- PROCESS_TIMEOUT = 1
10
- BROWSER_PATH = ENV["BROWSER_PATH"]
11
- BROWSER_HOST = "127.0.0.1"
12
- BROWSER_PORT = "0"
13
- DEFAULT_OPTIONS = {
14
- "headless" => nil,
15
- "disable-gpu" => nil,
16
- "hide-scrollbars" => nil,
17
- "mute-audio" => nil,
18
- "enable-automation" => nil,
19
- "disable-web-security" => nil,
20
- "disable-session-crashed-bubble" => nil,
21
- "disable-breakpad" => nil,
22
- "disable-sync" => nil,
23
- "no-first-run" => nil,
24
- "use-mock-keychain" => nil,
25
- "keep-alive-for-test" => nil,
26
- "disable-popup-blocking" => nil,
27
- "disable-extensions" => nil,
28
- "disable-hang-monitor" => nil,
29
- "disable-features" => "site-per-process,TranslateUI",
30
- "disable-translate" => nil,
31
- "disable-background-networking" => nil,
32
- "enable-features" => "NetworkService,NetworkServiceInProcess",
33
- "disable-background-timer-throttling" => nil,
34
- "disable-backgrounding-occluded-windows" => nil,
35
- "disable-client-side-phishing-detection" => nil,
36
- "disable-default-apps" => nil,
37
- "disable-dev-shm-usage" => nil,
38
- "disable-ipc-flooding-protection" => nil,
39
- "disable-prompt-on-repost" => nil,
40
- "disable-renderer-backgrounding" => nil,
41
- "force-color-profile" => "srgb",
42
- "metrics-recording-only" => nil,
43
- "safebrowsing-disable-auto-update" => nil,
44
- "password-store" => "basic",
45
- # Note: --no-sandbox is not needed if you properly setup a user in the container.
46
- # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
47
- # "no-sandbox" => nil,
48
- }.freeze
49
-
50
- attr_reader :host, :port, :ws_url, :pid, :path, :options, :cmd
51
-
52
- def self.start(*args)
53
- new(*args).tap(&:start)
54
- end
55
-
56
- def self.process_killer(pid)
57
- proc do
58
- begin
59
- if Capybara::Cuprite.windows?
60
- ::Process.kill("KILL", pid)
61
- else
62
- ::Process.kill("USR1", pid)
63
- start = Capybara::Helpers.monotonic_time
64
- while ::Process.wait(pid, ::Process::WNOHANG).nil?
65
- sleep 0.05
66
- next unless (Capybara::Helpers.monotonic_time - start) > KILL_TIMEOUT
67
- ::Process.kill("KILL", pid)
68
- ::Process.wait(pid)
69
- break
70
- end
71
- end
72
- rescue Errno::ESRCH, Errno::ECHILD
73
- end
74
- end
75
- end
76
-
77
- def initialize(options)
78
- @options = {}
79
-
80
- detect_browser_path(options)
81
-
82
- # Doesn't work on MacOS, so we need to set it by CDP as well
83
- @options.merge!("window-size" => options[:window_size].join(","))
84
-
85
- port = options.fetch(:port, BROWSER_PORT)
86
- @options.merge!("remote-debugging-port" => port)
87
-
88
- host = options.fetch(:host, BROWSER_HOST)
89
- @options.merge!("remote-debugging-address" => host)
90
-
91
- @options.merge!("user-data-dir" => Dir.mktmpdir)
92
-
93
- @options = DEFAULT_OPTIONS.merge(@options)
94
-
95
- unless options.fetch(:headless, true)
96
- @options.delete("headless")
97
- @options.delete("disable-gpu")
98
- end
99
-
100
- @process_timeout = options.fetch(:process_timeout, PROCESS_TIMEOUT)
101
-
102
- @options.merge!(options.fetch(:browser_options, {}))
103
-
104
- @logger = options.fetch(:logger, nil)
105
- end
106
-
107
- def start
108
- read_io, write_io = IO.pipe
109
- process_options = { in: File::NULL }
110
- process_options[:pgroup] = true unless Capybara::Cuprite.windows?
111
- if Capybara::Cuprite.mri?
112
- process_options[:out] = process_options[:err] = write_io
113
- end
114
-
115
- redirect_stdout(write_io) do
116
- @cmd = [@path] + @options.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
117
- @pid = ::Process.spawn(*@cmd, process_options)
118
- ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
119
- end
120
-
121
- parse_ws_url(read_io, @process_timeout)
122
- ensure
123
- close_io(read_io, write_io)
124
- end
125
-
126
- def stop
127
- return unless @pid
128
- kill
129
- ObjectSpace.undefine_finalizer(self)
130
- end
131
-
132
- def restart
133
- stop
134
- start
135
- end
136
-
137
- private
138
-
139
- def detect_browser_path(options)
140
- @path =
141
- options[:browser_path] ||
142
- BROWSER_PATH || (
143
- if RUBY_PLATFORM.include?('darwin')
144
- [
145
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
146
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
147
- ].find { |path| File.exist?(path) }
148
- else
149
- %w[chromium google-chrome-unstable google-chrome-beta google-chrome chrome chromium-browser google-chrome-stable].reduce(nil) do |path, exe|
150
- path = Cliver.detect(exe)
151
- break path if path
152
- end
153
- end
154
- )
155
-
156
- unless @path
157
- message = "Could not find an executable for chrome. Try to make it " \
158
- "available on the PATH or set environment varible for " \
159
- "example BROWSER_PATH=\"/Applications/Chromium.app/Contents/MacOS/Chromium\""
160
- raise Cliver::Dependency::NotFound.new(message)
161
- end
162
- end
163
-
164
- def redirect_stdout(write_io)
165
- if Capybara::Cuprite.mri?
166
- yield
167
- else
168
- begin
169
- prev = STDOUT.dup
170
- $stdout = write_io
171
- STDOUT.reopen(write_io)
172
- yield
173
- ensure
174
- STDOUT.reopen(prev)
175
- $stdout = STDOUT
176
- prev.close
177
- end
178
- end
179
- end
180
-
181
- def kill
182
- self.class.process_killer(@pid).call
183
- @pid = nil
184
- end
185
-
186
- def parse_ws_url(read_io, timeout = PROCESS_TIMEOUT)
187
- output = ""
188
- start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
189
- max_time = start + timeout
190
- regexp = /DevTools listening on (ws:\/\/.*)/
191
- while (now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)) < max_time
192
- begin
193
- output += read_io.read_nonblock(512)
194
- rescue IO::WaitReadable
195
- IO.select([read_io], nil, nil, max_time - now)
196
- else
197
- if output.match(regexp)
198
- @ws_url = Addressable::URI.parse(output.match(regexp)[1].strip)
199
- @host = @ws_url.host
200
- @port = @ws_url.port
201
- break
202
- end
203
- end
204
- end
205
-
206
- unless @ws_url
207
- @logger.puts output if @logger
208
- raise "Chrome process did not produce websocket url within #{timeout} seconds"
209
- end
210
- end
211
-
212
- def close_io(*ios)
213
- ios.each do |io|
214
- begin
215
- io.close unless io.closed?
216
- rescue IOError
217
- raise unless RUBY_ENGINE == 'jruby'
218
- end
219
- end
220
- end
221
- end
222
- end
223
- end
@@ -1,182 +0,0 @@
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
- begin
80
- attempts ||= 1
81
- response = command("Runtime.callFunctionOn", **options)
82
- response.dig("result").tap { |r| handle_error(r) }
83
- rescue BrowserError => e
84
- case e.message
85
- when "No node with given id found", "Could not find node with given id", "Cannot find context with specified id"
86
- sleep 0.1
87
- attempts += 1
88
- options = options.merge(executionContextId: execution_context_id)
89
- retry if attempts <= 3
90
- end
91
- end
92
- end
93
-
94
- # FIXME: We should have a central place to handle all type of errors
95
- def handle_error(result)
96
- return if result["subtype"] != "error"
97
-
98
- case result["className"]
99
- when "TimedOutPromise"
100
- raise ScriptTimeoutError
101
- when "MouseEventFailed"
102
- raise MouseEventFailed.new(result["description"])
103
- else
104
- raise JavaScriptError.new(result)
105
- end
106
- end
107
-
108
- def prepare_args(args)
109
- args.map do |arg|
110
- if arg.is_a?(Node)
111
- node_id = arg.native.node["nodeId"]
112
- resolved = command("DOM.resolveNode", nodeId: node_id)
113
- { objectId: resolved["object"]["objectId"] }
114
- elsif arg.is_a?(Hash) && arg["objectId"]
115
- { objectId: arg["objectId"] }
116
- else
117
- { value: arg }
118
- end
119
- end
120
- end
121
-
122
- def handle(response)
123
- case response["type"]
124
- when "boolean", "number", "string"
125
- response["value"]
126
- when "undefined"
127
- nil
128
- when "function"
129
- {}
130
- when "object"
131
- object_id = response["objectId"]
132
-
133
- case response["subtype"]
134
- when "node"
135
- begin
136
- node_id = command("DOM.requestNode", objectId: object_id)["nodeId"]
137
- node = command("DOM.describeNode", nodeId: node_id)["node"].merge("nodeId" => node_id)
138
- { "target_id" => target_id, "node" => node }
139
- rescue BrowserError => e
140
- # Node has disappeared while we were trying to get it
141
- raise if e.message != "Could not find node with given id"
142
- end
143
- when "array"
144
- reduce_props(object_id, []) do |memo, key, value|
145
- next(memo) unless (Integer(key) rescue nil)
146
- value = value["objectId"] ? handle(value) : value["value"]
147
- memo.insert(key.to_i, value)
148
- end.compact
149
- when "date"
150
- response["description"]
151
- when "null"
152
- nil
153
- else
154
- reduce_props(object_id, {}) do |memo, key, value|
155
- value = value["objectId"] ? handle(value) : value["value"]
156
- memo.merge(key => value)
157
- end
158
- end
159
- end
160
- end
161
-
162
- def reduce_props(object_id, to)
163
- if cyclic?(object_id).dig("result", "value")
164
- return "(cyclic structure)"
165
- else
166
- props = command("Runtime.getProperties", objectId: object_id)
167
- props["result"].reduce(to) do |memo, prop|
168
- next(memo) unless prop["enumerable"]
169
- yield(memo, prop["name"], prop["value"])
170
- end
171
- end
172
- end
173
-
174
- def cyclic?(object_id)
175
- command("Runtime.callFunctionOn",
176
- objectId: object_id,
177
- returnByValue: true,
178
- functionDeclaration: "function() { return _cuprite.isCyclic(this); }")
179
- end
180
- end
181
- end
182
- end
@@ -1,129 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Capybara::Cuprite
4
- class Browser
5
- class Targets
6
- def initialize(browser)
7
- @mutex = Mutex.new
8
- @browser = browser
9
- @_default = targets.first["targetId"]
10
-
11
- @browser.subscribe("Target.detachedFromTarget") do |params|
12
- page = remove_page(params["targetId"])
13
- page&.close_connection
14
- end
15
-
16
- reset
17
- end
18
-
19
- def push(target_id, page = nil)
20
- @targets[target_id] = page
21
- end
22
-
23
- def refresh
24
- @mutex.synchronize do
25
- targets.each { |t| push(t["targetId"]) if !default?(t) && !has?(t) }
26
- end
27
- end
28
-
29
- def page
30
- raise NoSuchWindowError unless @page
31
- @page
32
- end
33
-
34
- def window_handle
35
- page.target_id
36
- end
37
-
38
- def window_handles
39
- @mutex.synchronize { @targets.keys }
40
- end
41
-
42
- def switch_to_window(target_id)
43
- @page = find_or_create_page(target_id)
44
- end
45
-
46
- def open_new_window
47
- target_id = @browser.command("Target.createTarget", url: "about:blank", browserContextId: @_context_id)["targetId"]
48
- page = Page.new(target_id, @browser)
49
- push(target_id, page)
50
- target_id
51
- end
52
-
53
- def close_window(target_id)
54
- remove_page(target_id)&.close
55
- end
56
-
57
- def within_window(locator)
58
- original = window_handle
59
-
60
- if Capybara::VERSION.to_f < 3.0
61
- target_id = window_handles.find do |target_id|
62
- page = find_or_create_page(target_id)
63
- locator == page.frame_name
64
- end
65
- locator = target_id if target_id
66
- end
67
-
68
- if window_handles.include?(locator)
69
- switch_to_window(locator)
70
- yield
71
- else
72
- raise NoSuchWindowError
73
- end
74
- ensure
75
- switch_to_window(original)
76
- end
77
-
78
- def reset
79
- if @page
80
- @page.close
81
- @browser.command("Target.disposeBrowserContext", browserContextId: @_context_id)
82
- end
83
-
84
- @page = nil
85
- @targets = {}
86
- @_context_id = nil
87
-
88
- @_context_id = @browser.command("Target.createBrowserContext")["browserContextId"]
89
- target_id = @browser.command("Target.createTarget", url: "about:blank", browserContextId: @_context_id)["targetId"]
90
- @page = Page.new(target_id, @browser)
91
- push(target_id, @page)
92
- end
93
-
94
- private
95
-
96
- def find_or_create_page(target_id)
97
- page = @targets[target_id]
98
- page ||= Page.new(target_id, @browser)
99
- @targets[target_id] ||= page
100
- page
101
- end
102
-
103
- def remove_page(target_id)
104
- page = @targets.delete(target_id)
105
- @page = nil if page && @page == page
106
- page
107
- end
108
-
109
- def targets
110
- attempts ||= 1
111
- # Targets cannot be empty the must be at least one default target.
112
- targets = @browser.command("Target.getTargets")["targetInfos"]
113
- raise TypeError if targets.empty?
114
- targets
115
- rescue TypeError
116
- attempts += 1
117
- retry if attempts < 3
118
- end
119
-
120
- def default?(target)
121
- @_default == target["targetId"]
122
- end
123
-
124
- def has?(target)
125
- @targets.key?(target["targetId"])
126
- end
127
- end
128
- end
129
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "socket"
5
- require "websocket/driver"
6
-
7
- module Capybara::Cuprite
8
- class Browser
9
- class WebSocket
10
- attr_reader :url, :messages
11
-
12
- def initialize(url, logger)
13
- @url = url
14
- @logger = logger
15
- uri = URI.parse(@url)
16
- @sock = TCPSocket.new(uri.host, uri.port)
17
- @driver = ::WebSocket::Driver.client(self)
18
- @messages = Queue.new
19
-
20
- @driver.on(:open, &method(:on_open))
21
- @driver.on(:message, &method(:on_message))
22
- @driver.on(:close, &method(:on_close))
23
-
24
- @thread = Thread.new do
25
- begin
26
- while data = @sock.readpartial(512)
27
- @driver.parse(data)
28
- end
29
- rescue EOFError, Errno::ECONNRESET
30
- @messages.close
31
- end
32
- end
33
-
34
- @thread.priority = 1
35
-
36
- @driver.start
37
- end
38
-
39
- def on_open(_event)
40
- sleep 0.01 # https://github.com/faye/websocket-driver-ruby/issues/46
41
- end
42
-
43
- def on_message(event)
44
- data = JSON.parse(event.data)
45
- @messages.push(data)
46
- @logger&.puts(" <<< #{event.data}\n")
47
- end
48
-
49
- def on_close(_event)
50
- @messages.close
51
- @thread.kill
52
- end
53
-
54
- def send_message(data)
55
- json = data.to_json
56
- @driver.text(json)
57
- @logger&.puts("\n\n>>> #{json}")
58
- end
59
-
60
- def write(data)
61
- @sock.write(data)
62
- end
63
-
64
- def close
65
- @driver.close
66
- end
67
- end
68
- end
69
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Capybara::Cuprite::Network
4
- class Error
5
- def initialize(data)
6
- @data = data
7
- end
8
-
9
- def id
10
- @data["networkRequestId"]
11
- end
12
-
13
- def url
14
- @data["url"]
15
- end
16
-
17
- def description
18
- @data["text"]
19
- end
20
-
21
- def time
22
- @time ||= Time.strptime(@data["timestamp"].to_s, "%s")
23
- end
24
- end
25
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module Capybara::Cuprite::Network
6
- class Request
7
- attr_accessor :response, :error
8
-
9
- def initialize(data)
10
- @data = data
11
- end
12
-
13
- def id
14
- @data["id"]
15
- end
16
-
17
- def url
18
- @data["url"]
19
- end
20
-
21
- def method
22
- @data["method"]
23
- end
24
-
25
- def headers
26
- @data["headers"]
27
- end
28
-
29
- def time
30
- @time ||= Time.strptime(@data["time"].to_s, "%s")
31
- end
32
- end
33
- end
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Capybara::Cuprite::Network
4
- class Response
5
- attr_accessor :body_size
6
-
7
- def initialize(data)
8
- @data = data
9
- end
10
-
11
- def id
12
- @data["id"]
13
- end
14
-
15
- def url
16
- @data["url"]
17
- end
18
-
19
- def status
20
- @data["status"]
21
- end
22
-
23
- def status_text
24
- @data["statusText"]
25
- end
26
-
27
- def headers
28
- @data["headers"]
29
- end
30
-
31
- def headers_size
32
- @data["encodedDataLength"]
33
- end
34
-
35
- # FIXME: didn't check if we have it on redirect response
36
- def redirect_url
37
- @data["redirectURL"]
38
- end
39
-
40
- def content_type
41
- @content_type ||= @data.dig("headers", "contentType").sub(/;.*\z/, "")
42
- end
43
- end
44
- end