apparition 0.1.0 → 0.6.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.
- checksums.yaml +4 -4
- data/README.md +40 -4
- data/lib/capybara/apparition.rb +0 -2
- data/lib/capybara/apparition/browser.rb +75 -133
- data/lib/capybara/apparition/browser/cookie.rb +4 -16
- data/lib/capybara/apparition/browser/header.rb +2 -2
- data/lib/capybara/apparition/browser/launcher.rb +25 -0
- data/lib/capybara/apparition/browser/launcher/local.rb +213 -0
- data/lib/capybara/apparition/browser/launcher/remote.rb +55 -0
- data/lib/capybara/apparition/browser/page_manager.rb +90 -0
- data/lib/capybara/apparition/browser/window.rb +29 -29
- data/lib/capybara/apparition/configuration.rb +100 -0
- data/lib/capybara/apparition/console.rb +8 -1
- data/lib/capybara/apparition/dev_tools_protocol/remote_object.rb +23 -7
- data/lib/capybara/apparition/dev_tools_protocol/session.rb +3 -4
- data/lib/capybara/apparition/driver.rb +107 -35
- data/lib/capybara/apparition/driver/chrome_client.rb +13 -8
- data/lib/capybara/apparition/driver/response.rb +1 -1
- data/lib/capybara/apparition/driver/web_socket_client.rb +1 -0
- data/lib/capybara/apparition/errors.rb +3 -3
- data/lib/capybara/apparition/network_traffic/error.rb +1 -0
- data/lib/capybara/apparition/network_traffic/request.rb +5 -5
- data/lib/capybara/apparition/node.rb +142 -50
- data/lib/capybara/apparition/node/drag.rb +165 -65
- data/lib/capybara/apparition/page.rb +180 -142
- data/lib/capybara/apparition/page/frame.rb +3 -0
- data/lib/capybara/apparition/page/frame_manager.rb +2 -1
- data/lib/capybara/apparition/page/keyboard.rb +29 -7
- data/lib/capybara/apparition/page/mouse.rb +20 -6
- data/lib/capybara/apparition/utility.rb +1 -1
- data/lib/capybara/apparition/version.rb +1 -1
- metadata +53 -23
- data/lib/capybara/apparition/dev_tools_protocol/target.rb +0 -64
- data/lib/capybara/apparition/dev_tools_protocol/target_manager.rb +0 -48
- data/lib/capybara/apparition/driver/launcher.rb +0 -217
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'capybara/apparition/browser/launcher/local'
|
4
|
+
require 'capybara/apparition/browser/launcher/remote'
|
5
|
+
|
6
|
+
module Capybara::Apparition
|
7
|
+
class Browser
|
8
|
+
class Launcher
|
9
|
+
def self.start(options)
|
10
|
+
browser_options = options.fetch(:browser_options, {})
|
11
|
+
|
12
|
+
if options.fetch(:remote, false)
|
13
|
+
Remote.start(
|
14
|
+
browser_options
|
15
|
+
)
|
16
|
+
else
|
17
|
+
Local.start(
|
18
|
+
headless: options.fetch(:headless, true),
|
19
|
+
browser_options: browser_options
|
20
|
+
)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
|
5
|
+
module Capybara::Apparition
|
6
|
+
class Browser
|
7
|
+
class Launcher
|
8
|
+
class Local
|
9
|
+
KILL_TIMEOUT = 5
|
10
|
+
|
11
|
+
def self.start(*args, **options)
|
12
|
+
new(*args, **options).tap(&:start)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.process_killer(pid)
|
16
|
+
proc do
|
17
|
+
sleep 1
|
18
|
+
if Capybara::Apparition.windows?
|
19
|
+
::Process.kill('KILL', pid)
|
20
|
+
else
|
21
|
+
::Process.kill('USR1', pid)
|
22
|
+
timer = Capybara::Helpers.timer(expire_in: KILL_TIMEOUT)
|
23
|
+
while ::Process.wait(pid, ::Process::WNOHANG).nil?
|
24
|
+
sleep 0.05
|
25
|
+
next unless timer.expired?
|
26
|
+
|
27
|
+
::Process.kill('KILL', pid)
|
28
|
+
::Process.wait(pid)
|
29
|
+
break
|
30
|
+
end
|
31
|
+
end
|
32
|
+
rescue Errno::ESRCH, Errno::ECHILD # rubocop:disable Lint/SuppressedException
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(headless:, **options)
|
37
|
+
@path = ENV['BROWSER_PATH']
|
38
|
+
@options = DEFAULT_OPTIONS.merge(options[:browser_options] || {})
|
39
|
+
if headless
|
40
|
+
@options.merge!(HEADLESS_OPTIONS)
|
41
|
+
@options['disable-gpu'] = nil if Capybara::Apparition.windows?
|
42
|
+
end
|
43
|
+
@options['user-data-dir'] = Dir.mktmpdir
|
44
|
+
end
|
45
|
+
|
46
|
+
def start
|
47
|
+
@output = Queue.new
|
48
|
+
|
49
|
+
process_options = {}
|
50
|
+
process_options[:pgroup] = true unless Capybara::Apparition.windows?
|
51
|
+
cmd = [path] + @options.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
|
52
|
+
|
53
|
+
stdin, @stdout_stderr, wait_thr = Open3.popen2e(*cmd, process_options)
|
54
|
+
stdin.close
|
55
|
+
|
56
|
+
@pid = wait_thr.pid
|
57
|
+
|
58
|
+
@out_thread = Thread.new do
|
59
|
+
while !@stdout_stderr.eof? && (data = @stdout_stderr.readpartial(512))
|
60
|
+
@output << data
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
|
65
|
+
end
|
66
|
+
|
67
|
+
def stop
|
68
|
+
return unless @pid
|
69
|
+
|
70
|
+
kill
|
71
|
+
ObjectSpace.undefine_finalizer(self)
|
72
|
+
end
|
73
|
+
|
74
|
+
def restart
|
75
|
+
stop
|
76
|
+
start
|
77
|
+
end
|
78
|
+
|
79
|
+
def host
|
80
|
+
@host ||= ws_url.host
|
81
|
+
end
|
82
|
+
|
83
|
+
def port
|
84
|
+
@port ||= ws_url.port
|
85
|
+
end
|
86
|
+
|
87
|
+
def ws_url
|
88
|
+
@ws_url ||= begin
|
89
|
+
regexp = %r{DevTools listening on (ws://.*)}
|
90
|
+
url = nil
|
91
|
+
|
92
|
+
sleep 3
|
93
|
+
loop do
|
94
|
+
break if (url = @output.pop.scan(regexp)[0])
|
95
|
+
end
|
96
|
+
@out_thread.kill
|
97
|
+
@out_thread.join # wait for thread to end before closing io
|
98
|
+
close_io
|
99
|
+
Addressable::URI.parse(url[0])
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def kill
|
106
|
+
self.class.process_killer(@pid).call
|
107
|
+
@pid = nil
|
108
|
+
end
|
109
|
+
|
110
|
+
def close_io
|
111
|
+
@stdout_stderr.close unless @stdout_stderr.closed?
|
112
|
+
rescue IOError
|
113
|
+
raise unless RUBY_ENGINE == 'jruby'
|
114
|
+
end
|
115
|
+
|
116
|
+
def path
|
117
|
+
host_os = RbConfig::CONFIG['host_os']
|
118
|
+
@path ||= case RbConfig::CONFIG['host_os']
|
119
|
+
when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
|
120
|
+
windows_path
|
121
|
+
when /darwin|mac os/
|
122
|
+
macosx_path
|
123
|
+
when /linux|solaris|bsd/
|
124
|
+
linux_path
|
125
|
+
else
|
126
|
+
raise ArgumentError, "unknown os: #{host_os.inspect}"
|
127
|
+
end
|
128
|
+
|
129
|
+
raise ArgumentError, 'Unable to find Chrome executeable' unless File.file?(@path.to_s) && File.executable?(@path.to_s)
|
130
|
+
|
131
|
+
@path
|
132
|
+
end
|
133
|
+
|
134
|
+
def windows_path
|
135
|
+
envs = %w[LOCALAPPDATA PROGRAMFILES PROGRAMFILES(X86)]
|
136
|
+
directories = %w[\\Google\\Chrome\\Application \\Chromium\\Application]
|
137
|
+
files = %w[chrome.exe]
|
138
|
+
|
139
|
+
directories.product(envs, files).lazy.map { |(dir, env, file)| "#{ENV[env]}\\#{dir}\\#{file}" }
|
140
|
+
.find { |f| File.exist?(f) } || find_first_binary(*files)
|
141
|
+
end
|
142
|
+
|
143
|
+
def macosx_path
|
144
|
+
directories = ['', File.expand_path('~')]
|
145
|
+
files = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
146
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium']
|
147
|
+
directories.product(files).map(&:join).find { |f| File.exist?(f) } ||
|
148
|
+
find_first_binary('Google Chrome', 'Chromium')
|
149
|
+
end
|
150
|
+
|
151
|
+
def linux_path
|
152
|
+
directories = %w[/usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin /opt/google/chrome]
|
153
|
+
files = %w[google-chrome chrome chromium chromium-browser]
|
154
|
+
|
155
|
+
directories.product(files).map { |p| p.join('/') }.find { |f| File.exist?(f) } ||
|
156
|
+
find_first_binary(*files)
|
157
|
+
end
|
158
|
+
|
159
|
+
def find_first_binary(*binaries)
|
160
|
+
paths = ENV['PATH'].split(File::PATH_SEPARATOR)
|
161
|
+
|
162
|
+
binaries.product(paths).lazy.map do |(binary, path)|
|
163
|
+
Dir.glob(File.join(path, binary)).find { |f| File.executable?(f) }
|
164
|
+
end.reject(&:nil?).first
|
165
|
+
end
|
166
|
+
|
167
|
+
# Chromium command line options
|
168
|
+
# https://peter.sh/experiments/chromium-command-line-switches/
|
169
|
+
DEFAULT_BOOLEAN_OPTIONS = %w[
|
170
|
+
disable-background-networking
|
171
|
+
disable-background-timer-throttling
|
172
|
+
disable-breakpad
|
173
|
+
disable-client-side-phishing-detection
|
174
|
+
disable-default-apps
|
175
|
+
disable-dev-shm-usage
|
176
|
+
disable-extensions
|
177
|
+
disable-features=site-per-process
|
178
|
+
disable-hang-monitor
|
179
|
+
disable-infobars
|
180
|
+
disable-popup-blocking
|
181
|
+
disable-prompt-on-repost
|
182
|
+
disable-sync
|
183
|
+
disable-translate
|
184
|
+
disable-session-crashed-bubble
|
185
|
+
metrics-recording-only
|
186
|
+
no-first-run
|
187
|
+
safebrowsing-disable-auto-update
|
188
|
+
enable-automation
|
189
|
+
password-store=basic
|
190
|
+
use-mock-keychain
|
191
|
+
keep-alive-for-test
|
192
|
+
].freeze
|
193
|
+
# Note: --no-sandbox is not needed if you properly setup a user in the container.
|
194
|
+
# https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
|
195
|
+
# no-sandbox
|
196
|
+
# disable-web-security
|
197
|
+
DEFAULT_VALUE_OPTIONS = {
|
198
|
+
'window-size' => '1024,768',
|
199
|
+
'homepage' => 'about:blank',
|
200
|
+
'remote-debugging-address' => '127.0.0.1'
|
201
|
+
}.freeze
|
202
|
+
DEFAULT_OPTIONS = DEFAULT_BOOLEAN_OPTIONS.each_with_object({}) { |opt, hsh| hsh[opt] = nil }
|
203
|
+
.merge(DEFAULT_VALUE_OPTIONS)
|
204
|
+
.freeze
|
205
|
+
HEADLESS_OPTIONS = {
|
206
|
+
'headless' => nil,
|
207
|
+
'hide-scrollbars' => nil,
|
208
|
+
'mute-audio' => nil
|
209
|
+
}.freeze
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'socket'
|
4
|
+
require 'timeout'
|
5
|
+
|
6
|
+
module Capybara::Apparition
|
7
|
+
class Browser
|
8
|
+
class Launcher
|
9
|
+
class Remote
|
10
|
+
attr_reader :ws_url
|
11
|
+
|
12
|
+
def self.start(options)
|
13
|
+
new(options).tap(&:start)
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(options)
|
17
|
+
@remote_host = options.fetch('remote-debugging-address', '127.0.0.1')
|
18
|
+
@remote_port = options.fetch('remote-debugging-port', '9222')
|
19
|
+
end
|
20
|
+
|
21
|
+
def start
|
22
|
+
@ws_url = Addressable::URI.parse(get_ws_url(@remote_host, @remote_port))
|
23
|
+
|
24
|
+
true
|
25
|
+
end
|
26
|
+
|
27
|
+
def stop
|
28
|
+
# Remote instance cannot be stopped
|
29
|
+
end
|
30
|
+
|
31
|
+
def restart
|
32
|
+
# Remote instance cannot be restarted
|
33
|
+
end
|
34
|
+
|
35
|
+
def host
|
36
|
+
ws_url.host
|
37
|
+
end
|
38
|
+
|
39
|
+
def port
|
40
|
+
ws_url.port
|
41
|
+
end
|
42
|
+
|
43
|
+
protected
|
44
|
+
|
45
|
+
def get_ws_url(host, port)
|
46
|
+
response = Net::HTTP.get(host, '/json/version', port)
|
47
|
+
response = JSON.parse(response)
|
48
|
+
response['webSocketDebuggerUrl']
|
49
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
50
|
+
raise ArgumentError, "Cannot connect to remote Chrome at: 'http://#{host}:#{port}/json/version'"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Capybara::Apparition
|
4
|
+
class Browser
|
5
|
+
class PageManager
|
6
|
+
def initialize(browser)
|
7
|
+
@browser = browser
|
8
|
+
@pages = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def ids
|
12
|
+
@pages.keys
|
13
|
+
end
|
14
|
+
|
15
|
+
def [](id)
|
16
|
+
@pages[id]
|
17
|
+
end
|
18
|
+
|
19
|
+
def each(&block)
|
20
|
+
@pages.each_value(&block)
|
21
|
+
end
|
22
|
+
|
23
|
+
def reset
|
24
|
+
@pages.each do |id, page|
|
25
|
+
begin
|
26
|
+
@browser.client.send_cmd(
|
27
|
+
'Target.disposeBrowserContext',
|
28
|
+
browserContextId: page.browser_context_id
|
29
|
+
).discard_result
|
30
|
+
rescue WrongWorld
|
31
|
+
puts 'Unknown browserContextId'
|
32
|
+
end
|
33
|
+
@pages.delete(id)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def create(id, session, ctx_id, **options)
|
38
|
+
@pages[id] = Page.create(@browser, session, id, ctx_id, **options)
|
39
|
+
end
|
40
|
+
|
41
|
+
def delete(id)
|
42
|
+
@pages.delete(id)
|
43
|
+
end
|
44
|
+
|
45
|
+
def refresh(opener:, **page_options)
|
46
|
+
new_pages = @browser.command('Target.getTargets')['targetInfos'].select do |ti|
|
47
|
+
(ti['openerId'] == opener.target_id) && (ti['type'] == 'page') && (ti['attached'] == false)
|
48
|
+
end
|
49
|
+
|
50
|
+
sessions = new_pages.map do |page|
|
51
|
+
target_id = page['targetId']
|
52
|
+
session_result = @browser.client.send_cmd('Target.attachToTarget', targetId: target_id)
|
53
|
+
[target_id, session_result]
|
54
|
+
end
|
55
|
+
|
56
|
+
sessions = sessions.map do |(target_id, session_result)|
|
57
|
+
session = Capybara::Apparition::DevToolsProtocol::Session.new(
|
58
|
+
@browser,
|
59
|
+
@browser.client,
|
60
|
+
session_result.result['sessionId']
|
61
|
+
)
|
62
|
+
[target_id, session]
|
63
|
+
end
|
64
|
+
|
65
|
+
sessions.each do |(_id, session)|
|
66
|
+
session.async_commands 'Page.enable', 'Network.enable', 'Runtime.enable', 'Security.enable', 'DOM.enable'
|
67
|
+
end
|
68
|
+
|
69
|
+
sessions.each do |(target_id, session)|
|
70
|
+
new_page = Page.create(
|
71
|
+
@browser,
|
72
|
+
session,
|
73
|
+
target_id,
|
74
|
+
opener.browser_context_id,
|
75
|
+
**page_options
|
76
|
+
).inherit(opener)
|
77
|
+
@pages[target_id] = new_page
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def whitelist=(list)
|
82
|
+
@pages.each_value { |page| page.url_whitelist = list }
|
83
|
+
end
|
84
|
+
|
85
|
+
def blacklist=(list)
|
86
|
+
@pages.each_value { |page| page.url_blacklist = list }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -3,64 +3,64 @@
|
|
3
3
|
module Capybara::Apparition
|
4
4
|
class Browser
|
5
5
|
module Window
|
6
|
-
def
|
6
|
+
def current_window_handle
|
7
7
|
@current_page_handle
|
8
8
|
end
|
9
9
|
|
10
10
|
def window_handles
|
11
|
-
@
|
11
|
+
@pages.ids
|
12
12
|
end
|
13
13
|
|
14
14
|
def switch_to_window(handle)
|
15
|
-
|
16
|
-
|
15
|
+
page = @pages[handle]
|
16
|
+
unless page
|
17
|
+
page = @pages[find_window_handle(handle)]
|
18
|
+
warn 'Finding window by name, title, or url is deprecated, please use a block/proc ' \
|
19
|
+
'with Session#within_window/Session#switch_to_window instead.'
|
20
|
+
end
|
21
|
+
raise NoSuchWindowError unless page
|
17
22
|
|
18
|
-
|
19
|
-
@current_page_handle =
|
23
|
+
page.wait_for_loaded
|
24
|
+
@current_page_handle = page.target_id
|
20
25
|
end
|
21
26
|
|
22
27
|
def open_new_window
|
23
|
-
context_id =
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
28
|
+
context_id = current_page.browser_context_id
|
29
|
+
target_id = command('Target.createTarget', url: 'about:blank', browserContextId: context_id)['targetId']
|
30
|
+
session_id = command('Target.attachToTarget', targetId: target_id)['sessionId']
|
31
|
+
session = Capybara::Apparition::DevToolsProtocol::Session.new(self, client, session_id)
|
32
|
+
@pages.create(target_id, session, context_id,
|
33
|
+
ignore_https_errors: ignore_https_errors,
|
34
|
+
js_errors: js_errors,
|
35
|
+
url_whitelist: @url_whitelist,
|
36
|
+
extensions: @extensions,
|
37
|
+
url_blacklist: @url_blacklist).inherit(current_page(allow_nil: true))
|
38
|
+
@pages[target_id].send(:main_frame).loaded!
|
34
39
|
target_id
|
35
40
|
end
|
36
41
|
|
37
42
|
def close_window(handle)
|
38
43
|
@current_page_handle = nil if @current_page_handle == handle
|
39
|
-
|
40
|
-
warn 'Window was already closed unexpectedly' if win_target.nil?
|
41
|
-
win_target&.close
|
42
|
-
end
|
44
|
+
page = @pages.delete(handle)
|
43
45
|
|
44
|
-
|
45
|
-
|
46
|
-
handle = find_window_handle(locator)
|
47
|
-
switch_to_window(handle)
|
48
|
-
yield
|
49
|
-
ensure
|
50
|
-
switch_to_window(original)
|
46
|
+
warn 'Window was already closed unexpectedly' unless page
|
47
|
+
command('Target.closeTarget', targetId: handle)
|
51
48
|
end
|
52
49
|
end
|
53
50
|
|
54
51
|
private
|
55
52
|
|
56
53
|
def find_window_handle(locator)
|
54
|
+
original = current_window_handle
|
57
55
|
return locator if window_handles.include? locator
|
58
56
|
|
59
57
|
window_handles.each do |handle|
|
60
58
|
switch_to_window(handle)
|
61
|
-
return handle if evaluate('window.name')
|
59
|
+
return handle if evaluate('[window.name, document.title, window.location.href]').include? locator
|
62
60
|
end
|
63
61
|
raise NoSuchWindowError
|
62
|
+
ensure
|
63
|
+
switch_to_window(original) if original
|
64
64
|
end
|
65
65
|
end
|
66
66
|
end
|