cuprite 0.6.0 → 0.7.1

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