apparition 0.1.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -4
  3. data/lib/capybara/apparition.rb +0 -2
  4. data/lib/capybara/apparition/browser.rb +75 -133
  5. data/lib/capybara/apparition/browser/cookie.rb +4 -16
  6. data/lib/capybara/apparition/browser/header.rb +2 -2
  7. data/lib/capybara/apparition/browser/launcher.rb +25 -0
  8. data/lib/capybara/apparition/browser/launcher/local.rb +213 -0
  9. data/lib/capybara/apparition/browser/launcher/remote.rb +55 -0
  10. data/lib/capybara/apparition/browser/page_manager.rb +90 -0
  11. data/lib/capybara/apparition/browser/window.rb +29 -29
  12. data/lib/capybara/apparition/configuration.rb +100 -0
  13. data/lib/capybara/apparition/console.rb +8 -1
  14. data/lib/capybara/apparition/dev_tools_protocol/remote_object.rb +23 -7
  15. data/lib/capybara/apparition/dev_tools_protocol/session.rb +3 -4
  16. data/lib/capybara/apparition/driver.rb +107 -35
  17. data/lib/capybara/apparition/driver/chrome_client.rb +13 -8
  18. data/lib/capybara/apparition/driver/response.rb +1 -1
  19. data/lib/capybara/apparition/driver/web_socket_client.rb +1 -0
  20. data/lib/capybara/apparition/errors.rb +3 -3
  21. data/lib/capybara/apparition/network_traffic/error.rb +1 -0
  22. data/lib/capybara/apparition/network_traffic/request.rb +5 -5
  23. data/lib/capybara/apparition/node.rb +142 -50
  24. data/lib/capybara/apparition/node/drag.rb +165 -65
  25. data/lib/capybara/apparition/page.rb +180 -142
  26. data/lib/capybara/apparition/page/frame.rb +3 -0
  27. data/lib/capybara/apparition/page/frame_manager.rb +2 -1
  28. data/lib/capybara/apparition/page/keyboard.rb +29 -7
  29. data/lib/capybara/apparition/page/mouse.rb +20 -6
  30. data/lib/capybara/apparition/utility.rb +1 -1
  31. data/lib/capybara/apparition/version.rb +1 -1
  32. metadata +53 -23
  33. data/lib/capybara/apparition/dev_tools_protocol/target.rb +0 -64
  34. data/lib/capybara/apparition/dev_tools_protocol/target_manager.rb +0 -48
  35. 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 window_handle
6
+ def current_window_handle
7
7
  @current_page_handle
8
8
  end
9
9
 
10
10
  def window_handles
11
- @targets.window_handles
11
+ @pages.ids
12
12
  end
13
13
 
14
14
  def switch_to_window(handle)
15
- target = @targets.get(handle)
16
- raise NoSuchWindowError unless target&.page
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
- target.page.wait_for_loaded
19
- @current_page_handle = 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 = current_target.context_id
24
- info = command('Target.createTarget', url: 'about:blank', browserContextId: context_id)
25
- target_id = info['targetId']
26
- target = DevToolsProtocol::Target.new(self, info.merge('type' => 'page', 'inherit' => current_page))
27
- target.page # Ensure page object construction happens
28
- begin
29
- puts "Adding #{target_id} - #{target.info}" if ENV['DEBUG']
30
- @targets.add(target_id, target)
31
- rescue ArgumentError
32
- puts 'Target already existed' if ENV['DEBUG']
33
- end
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
- win_target = @targets.delete(handle)
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
- def within_window(locator)
45
- original = window_handle
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') == locator
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