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