apparition 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbed62503718fca5fe4664a248445c8b222073b352e2afd1422eeb85cf7bbf40
4
- data.tar.gz: 508d1a1883ccc043b26a617ce84bbfb30ed1a72eee407965b10a50a12e2a3a6d
3
+ metadata.gz: 3a8ccd4ce950f7c17f8ab0ac0c5ddcbbc13a336f428011d66f4b3cb028ced150
4
+ data.tar.gz: c524d1e955e3e4e958d0bd64350190eec6f53c7f612a6bd1b41867c05ab11427
5
5
  SHA512:
6
- metadata.gz: 90fccfe118998a00935b4ff9c6637747a73ea917927e15db3bc29b6a02aa9ecd763bde7d8f65043b377e33c7357ff8fbe38f8064970e7e79de5aeac37bb6bcf2
7
- data.tar.gz: 3e2220019a00bd43c8125c7ab6d75f6fb7b8b4430e57e28fe22dea73012b2b8c61fea655e1a455cb1dfa49d55cafadbf09ffc80621f1e43745c0791add88a5e1
6
+ metadata.gz: 1d772071df2776e86a611a07b718eb3c411cf9048e466d66515920261f6aa8b6705ddd1559d3fa8c8728fe3510a58b37b82986d698ad17f5add7f7399a9d4fc8
7
+ data.tar.gz: bdd2bdfba3591b9a798cc820ff4dd8ce7d90af853f79c716828e3bbb2a51810c0e2f4705c16181b9284533569bd4f050fa94d95f1b62d3c5fc2880d29d817a72
data/README.md CHANGED
@@ -179,8 +179,9 @@ end
179
179
  `options` is a hash of options. The following options are supported:
180
180
 
181
181
  * `:headless` (Boolean) - When false, run the browser visibly
182
+ * `:remote` (Boolean) - When true, connect to remote browser instead of starting locally (see [below](#Remote Chrome Driver))
182
183
  * `:debug` (Boolean) - When true, debug output is logged to `STDERR`.
183
- * `:logger` (Object responding to `puts`) - When present, debug output is written to this object
184
+ * `:logger` (Ruby logger object or any object responding to `puts`) - When present, debug output is written to this object
184
185
  * `:browser_logger` (`IO` object) - This is where your `console.log` statements will show up. Default: `STDOUT`
185
186
  * `:timeout` (Numeric) - The number of seconds we'll wait for a response
186
187
  when communicating with Chrome. Default is 30.
@@ -198,6 +199,18 @@ end
198
199
  * `:browser_options` (Hash) - Extra command line options to pass to Chrome when starting
199
200
  * `:skip_image_loading` (Boolean) - Don't load images
200
201
 
202
+ ### Remote Chrome Driver ###
203
+ Apparition can connect to already running instance of chrome.
204
+ Remote mode is useful when running tests in CI and chrome is available as separate docker container.
205
+
206
+ In order to use remote browser - set up apparition in the following way:
207
+ ```ruby
208
+ Capybara.register_driver :apparition do |app|
209
+ browser_options = { 'remote-debugging-address' => '127.0.0.1', 'remote-debugging-port' => 9999 }
210
+ Capybara::Apparition::Driver.new(app, remote: true, browser_options: browser_options)
211
+ end
212
+ ```
213
+
201
214
  ### URL Blacklisting & Whitelisting ###
202
215
  Apparition supports URL blacklisting, which allows you
203
216
  to prevent scripts from running on designated domains:
@@ -18,6 +18,7 @@ require 'time'
18
18
  module Capybara::Apparition
19
19
  class Browser
20
20
  attr_reader :client, :paper_size, :zoom_factor, :console, :proxy_auth
21
+
21
22
  extend Forwardable
22
23
 
23
24
  delegate %i[visit current_url status_code
@@ -93,7 +94,7 @@ module Capybara::Apparition
93
94
  ignore_https_errors: ignore_https_errors,
94
95
  js_errors: js_errors, extensions: @extensions,
95
96
  url_blacklist: @url_blacklist,
96
- url_whitelist: @url_whitelist) .send(:main_frame).loaded!
97
+ url_whitelist: @url_whitelist).send(:main_frame).loaded!
97
98
 
98
99
  timer = Capybara::Helpers.timer(expire_in: 10)
99
100
  until @pages[new_target_id].usable?
@@ -186,7 +187,13 @@ module Capybara::Apparition
186
187
  private
187
188
 
188
189
  def log(message)
189
- @logger&.puts message if ENV['DEBUG']
190
+ return unless @logger && ENV['DEBUG']
191
+
192
+ if @logger.respond_to?(:puts)
193
+ @logger.puts(message)
194
+ else
195
+ @logger.debug(message)
196
+ end
190
197
  end
191
198
 
192
199
  def initialize_handlers
@@ -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
@@ -8,8 +8,15 @@ module Capybara::Apparition
8
8
  end
9
9
 
10
10
  def log(type, message, **options)
11
+ return unless @logger
12
+
11
13
  @messages << OpenStruct.new(type: type, message: message, **options)
12
- @logger&.puts "#{type}: #{message}"
14
+ message_to_log = "#{type}: #{message}"
15
+ if @logger.respond_to?(:puts)
16
+ @logger.puts(message_to_log)
17
+ else
18
+ @logger.info(message_to_log)
19
+ end
13
20
  end
14
21
 
15
22
  def clear
@@ -3,8 +3,8 @@
3
3
  require 'uri'
4
4
  require 'forwardable'
5
5
  require 'capybara/apparition/driver/chrome_client'
6
- require 'capybara/apparition/driver/launcher'
7
6
  require 'capybara/apparition/configuration'
7
+ require 'capybara/apparition/browser/launcher'
8
8
 
9
9
  module Capybara::Apparition
10
10
  class Driver < Capybara::Driver::Base
@@ -64,10 +64,7 @@ module Capybara::Apparition
64
64
 
65
65
  def client
66
66
  @client ||= begin
67
- @launcher ||= Browser::Launcher.start(
68
- headless: options.fetch(:headless, true),
69
- browser_options: browser_options
70
- )
67
+ @launcher ||= Browser::Launcher.start(options)
71
68
  ws_url = @launcher.ws_url
72
69
  ::Capybara::Apparition::ChromeClient.client(ws_url.to_s)
73
70
  end
@@ -221,9 +218,7 @@ module Capybara::Apparition
221
218
  alias_method :header, :add_header
222
219
 
223
220
  def response_headers
224
- browser.response_headers.each_with_object({}) do |(key, value), hsh|
225
- hsh[key.split('-').map(&:capitalize).join('-')] = value
226
- end
221
+ browser.response_headers.transform_keys { |key| key.split('-').map(&:capitalize).join('-') }
227
222
  end
228
223
 
229
224
  def set_cookie(name, value = nil, options = {})
@@ -295,7 +290,7 @@ module Capybara::Apparition
295
290
  begin
296
291
  input = read.read_nonblock(80) # clear out the read buffer
297
292
  puts unless input&.end_with?("\n")
298
- rescue EOFError, IO::WaitReadable # rubocop:disable Lint/SuppressedException
293
+ rescue EOFError, IO::WaitReadable
299
294
  # Ignore problems reading from STDIN.
300
295
  end
301
296
  end
@@ -446,7 +441,7 @@ module Capybara::Apparition
446
441
  end
447
442
  end
448
443
  when Hash
449
- options.each_with_object({}) { |(option, val), hsh| hsh[option.to_s.tr('_', '-')] = val }
444
+ options.transform_keys { |option| option.to_s.tr('_', '-') }
450
445
  else
451
446
  raise ArgumentError, 'browser_options must be an Array or a Hash'
452
447
  end
@@ -523,7 +518,7 @@ module Capybara::Apparition
523
518
  object_cache[arg]
524
519
  when Hash
525
520
  if (arg['subtype'] == 'node') && arg['objectId']
526
- tag_name = arg['description'].split(/[\.#]/, 2)[0]
521
+ tag_name = arg['description'].split(/[.#]/, 2)[0]
527
522
  Capybara::Apparition::Node.new(self, browser.current_page, arg['objectId'], tag_name: tag_name)
528
523
  else
529
524
  object_cache[arg] = {}
@@ -206,9 +206,7 @@ module Capybara::Apparition
206
206
  event_name = event['method']
207
207
  handlers[event_name].each do |handler|
208
208
  puts "Calling handler for #{event_name}" if ENV['DEBUG'] == 'V'
209
- # TODO: Update this to use transform_keys when we dump Ruby 2.4
210
- # handler.call(event['params'].transform_keys(&method(:snake_sym)))
211
- handler.call(**event['params'].each_with_object({}) { |(k, v), hash| hash[snake_sym(k)] = v })
209
+ handler.call(**event['params'].transform_keys(&method(:snake_sym)))
212
210
  end
213
211
  end
214
212
 
@@ -224,10 +222,8 @@ module Capybara::Apparition
224
222
  @async_response_handler.abort_on_exception = true
225
223
 
226
224
  @listener = Thread.new do
227
- begin
228
- listen
229
- rescue EOFError # rubocop:disable Lint/SuppressedException
230
- end
225
+ listen
226
+ rescue EOFError # rubocop:disable Lint/SuppressedException
231
227
  end
232
228
  # @listener.abort_on_exception = true
233
229
  end
@@ -63,6 +63,7 @@ module Capybara::Apparition
63
63
 
64
64
  class Socket
65
65
  attr_reader :url
66
+
66
67
  def initialize(url)
67
68
  @url = url
68
69
  uri = URI.parse(url)
@@ -3,6 +3,7 @@
3
3
  module Capybara::Apparition::NetworkTraffic
4
4
  class Error
5
5
  attr_reader :url, :code, :description
6
+
6
7
  def initialize(url:, code:, description:)
7
8
  @url = url
8
9
  @code = code
@@ -30,7 +30,7 @@ module Capybara::Apparition
30
30
  def find(method, selector)
31
31
  js = method == :css ? FIND_CSS_JS : FIND_XPATH_JS
32
32
  evaluate_on(js, value: selector).map do |r_o|
33
- tag_name = r_o['description'].split(/[\.#]/, 2)[0]
33
+ tag_name = r_o['description'].split(/[.#]/, 2)[0]
34
34
  Capybara::Apparition::Node.new(driver, @page, r_o['objectId'], tag_name: tag_name)
35
35
  end
36
36
  rescue ::Capybara::Apparition::BrowserError => e
@@ -197,7 +197,7 @@ module Capybara::Apparition
197
197
  evaluate_on ELEMENT_DISABLED_JS
198
198
  end
199
199
 
200
- def click(keys = [], button: 'left', count: 1, **options)
200
+ def click(keys = [], button: 'left', count: 1, delay: 0, **options)
201
201
  pos = element_click_pos(**options)
202
202
  raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['click']) if pos.nil?
203
203
 
@@ -208,7 +208,7 @@ module Capybara::Apparition
208
208
  raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['click', test.selector, pos]) unless test.success
209
209
  end
210
210
 
211
- @page.mouse.click_at(**pos.merge(button: button, count: count, modifiers: keys))
211
+ @page.mouse.click_at(**pos.merge(button: button, count: count, modifiers: keys, delay: delay))
212
212
  if ENV['DEBUG']
213
213
  begin
214
214
  new_pos = element_click_pos(**options)
@@ -490,13 +490,21 @@ module Capybara::Apparition
490
490
  DevToolsProtocol::RemoteObject.new(@page, response['result'] || response['object']).value
491
491
  end
492
492
 
493
- def set_text(value, clear: nil, delay: 0, **_unused)
493
+ def set_text(value, clear: nil, delay: 0, rapid: nil, **_unused)
494
494
  value = value.to_s
495
495
  if value.empty? && clear.nil?
496
496
  evaluate_on CLEAR_ELEMENT_JS
497
497
  else
498
498
  focus
499
- _send_keys(*keys_to_send(value, clear), delay: delay)
499
+ if (rapid && (value.length >= 6)) || ((value.length > 30) && rapid != false)
500
+ _send_keys(*keys_to_send(value[0..2], clear), delay: delay)
501
+ driver.execute_script <<~JS, self, value[0...-3]
502
+ arguments[0].value = arguments[1]
503
+ JS
504
+ _send_keys(*keys_to_send(value[-3..-1], :none), delay: delay)
505
+ else
506
+ _send_keys(*keys_to_send(value, clear), delay: delay)
507
+ end
500
508
  end
501
509
  end
502
510
 
@@ -563,7 +571,7 @@ module Capybara::Apparition
563
571
  r_o = @page.element_from_point(x: x, y: y)
564
572
  return nil unless r_o && r_o['objectId']
565
573
 
566
- tag_name = r_o['description'].split(/[\.#]/, 2)[0]
574
+ tag_name = r_o['description'].split(/[.#]/, 2)[0]
567
575
  hit_node = Capybara::Apparition::Node.new(driver, @page, r_o['objectId'], tag_name: tag_name)
568
576
  result = begin
569
577
  evaluate_on(<<~JS, objectId: hit_node.id)
@@ -17,15 +17,13 @@ module Capybara::Apparition
17
17
  m.up(**other.visible_center)
18
18
  else
19
19
  @page.keyboard.with_keys(drop_modifiers) do
20
- begin
21
- other.scroll_if_needed
22
- sleep delay
23
- m.move_to(**other.visible_center)
24
- sleep delay
25
- ensure
26
- m.up
27
- sleep delay
28
- end
20
+ other.scroll_if_needed
21
+ sleep delay
22
+ m.move_to(**other.visible_center)
23
+ sleep delay
24
+ ensure
25
+ m.up
26
+ sleep delay
29
27
  end
30
28
  end
31
29
  end
@@ -47,7 +45,7 @@ module Capybara::Apparition
47
45
  def drop(*args)
48
46
  if args[0].is_a? String
49
47
  input = evaluate_on ATTACH_FILE
50
- tag_name = input['description'].split(/[\.#]/, 2)[0]
48
+ tag_name = input['description'].split(/[.#]/, 2)[0]
51
49
  input = Capybara::Apparition::Node.new(driver, @page, input['objectId'], tag_name: tag_name)
52
50
  input.set(args)
53
51
  evaluate_on DROP_FILE, objectId: input.id
@@ -197,7 +197,7 @@ module Capybara::Apparition
197
197
  js_escaped_selector = selector.gsub('\\', '\\\\\\').gsub('"', '\"')
198
198
  query = method == :css ? CSS_FIND_JS : XPATH_FIND_JS
199
199
  result = _raw_evaluate(format(query, selector: js_escaped_selector))
200
- (result || []).map { |r_o| [self, r_o['objectId'], tag_name: r_o['description'].split(/[\.#]/, 2)[0]] }
200
+ (result || []).map { |r_o| [self, r_o['objectId'], tag_name: r_o['description'].split(/[.#]/, 2)[0]] }
201
201
  rescue ::Capybara::Apparition::BrowserError => e
202
202
  raise unless /is not a valid (XPath expression|selector)/.match? e.name
203
203
 
@@ -300,7 +300,7 @@ module Capybara::Apparition
300
300
 
301
301
  def element_from_point(x:, y:)
302
302
  r_o = _raw_evaluate("document.elementFromPoint(#{x}, #{y})", context_id: main_frame.context_id)
303
- while r_o && (/^iframe/.match? r_o['description'])
303
+ while r_o&.[]('description')&.start_with?('iframe')
304
304
  frame_node = command('DOM.describeNode', objectId: r_o['objectId'])
305
305
  frame = @frames.get(frame_node.dig('node', 'frameId'))
306
306
  fo = frame_offset(frame)
@@ -366,7 +366,7 @@ module Capybara::Apparition
366
366
  end
367
367
 
368
368
  def async_command(name, **params)
369
- @browser.command_for_session(@session.session_id, name, **params).discard_result
369
+ @browser.command_for_session(@session.session_id, name, params).discard_result
370
370
  end
371
371
 
372
372
  def extra_headers
@@ -374,11 +374,9 @@ module Capybara::Apparition
374
374
  end
375
375
 
376
376
  def update_headers(async: false)
377
- method = async ? :async_command : :command
378
377
  if (ua = extra_headers.find { |k, _v| /^User-Agent$/i.match? k })
379
- send(method, 'Network.setUserAgentOverride', userAgent: ua[1])
378
+ send(async ? :async_command : :command, 'Network.setUserAgentOverride', userAgent: ua[1])
380
379
  end
381
- send(method, 'Network.setExtraHTTPHeaders', headers: extra_headers)
382
380
  setup_network_interception
383
381
  end
384
382
 
@@ -509,8 +507,6 @@ module Capybara::Apparition
509
507
  end
510
508
 
511
509
  @session.on 'Runtime.executionContextCreated' do |context:|
512
- puts "**** executionContextCreated: #{params}" if ENV['DEBUG']
513
- # context = params['context']
514
510
  frame_id = context.dig('auxData', 'frameId')
515
511
  if context.dig('auxData', 'isDefault') && frame_id
516
512
  if (frame = @frames.get(frame_id))
@@ -560,20 +556,6 @@ module Capybara::Apparition
560
556
  end
561
557
  end
562
558
 
563
- @session.on(
564
- 'Network.requestIntercepted'
565
- ) do |request:, interception_id:, auth_challenge: nil, is_navigation_request: nil, **|
566
- if auth_challenge
567
- if auth_challenge['source'] == 'Proxy'
568
- handle_proxy_auth(interception_id)
569
- else
570
- handle_user_auth(interception_id)
571
- end
572
- else
573
- process_intercepted_request(interception_id, request, is_navigation_request)
574
- end
575
- end
576
-
577
559
  @session.on 'Fetch.requestPaused' do |request:, request_id:, resource_type:, **|
578
560
  process_intercepted_fetch(request_id, request, resource_type)
579
561
  end
@@ -645,39 +627,13 @@ module Capybara::Apparition
645
627
 
646
628
  def setup_network_interception
647
629
  async_command 'Network.setCacheDisabled', cacheDisabled: true
648
- # async_command 'Fetch.enable', handleAuthRequests: true
649
- async_command 'Network.setRequestInterception', patterns: [{ urlPattern: '*' }]
650
- end
651
-
652
- def process_intercepted_request(interception_id, request, navigation)
653
- headers, url = request.values_at('headers', 'url')
654
-
655
- unless @temp_headers.empty? || navigation # rubocop:disable Style/IfUnlessModifier
656
- headers.delete_if { |name, value| @temp_headers[name] == value }
657
- end
658
- unless @temp_no_redirect_headers.empty? || !navigation
659
- headers.delete_if { |name, value| @temp_no_redirect_headers[name] == value }
660
- end
661
- if (accept = perm_headers.keys.find { |k| /accept/i.match? k })
662
- headers[accept] = perm_headers[accept]
663
- end
664
-
665
- if @url_blacklist.any? { |r| url.match Regexp.escape(r).gsub('\*', '.*?') }
666
- block_request(interception_id, 'Failed')
667
- elsif @url_whitelist.any?
668
- if @url_whitelist.any? { |r| url.match Regexp.escape(r).gsub('\*', '.*?') }
669
- continue_request(interception_id, headers: headers)
670
- else
671
- block_request(interception_id, 'Failed')
672
- end
673
- else
674
- continue_request(interception_id, headers: headers)
675
- end
630
+ async_command 'Fetch.enable', handleAuthRequests: true
676
631
  end
677
632
 
678
633
  def process_intercepted_fetch(interception_id, request, resource_type)
679
634
  navigation = (resource_type == 'Document')
680
635
  headers, url = request.values_at('headers', 'url')
636
+ headers = headers.merge(extra_headers)
681
637
 
682
638
  unless @temp_headers.empty? || navigation # rubocop:disable Style/IfUnlessModifier
683
639
  headers.delete_if { |name, value| @temp_headers[name] == value }
@@ -706,14 +662,6 @@ module Capybara::Apparition
706
662
  end
707
663
  end
708
664
 
709
- def continue_request(id, **params)
710
- async_command 'Network.continueInterceptedRequest', interceptionId: id, **params
711
- end
712
-
713
- def block_request(id, reason)
714
- async_command 'Network.continueInterceptedRequest', errorReason: reason, interceptionId: id
715
- end
716
-
717
665
  def go_history(delta)
718
666
  history = command('Page.getNavigationHistory')
719
667
  entry = history['entries'][history['currentIndex'] + delta]
@@ -66,6 +66,10 @@ module Capybara::Apparition
66
66
 
67
67
  private
68
68
 
69
+ def insert_emoji(str)
70
+ @page.command('Input.insertText', text: str)
71
+ end
72
+
69
73
  def type_with_modifiers(keys, delay:)
70
74
  old_pressed_keys, @pressed_keys = @pressed_keys, {}
71
75
 
@@ -74,9 +78,17 @@ module Capybara::Apparition
74
78
  when Array
75
79
  type_with_modifiers(sequence, delay: delay)
76
80
  when String
77
- sequence.each_char do |char|
78
- press char
79
- sleep delay
81
+ clusters = sequence.grapheme_clusters.chunk { |gc| gc.match?(/\p{Emoji Presentation}/) }
82
+ clusters.each do |emoji, chars|
83
+ if emoji
84
+ insert_emoji(chars.join)
85
+ sleep delay
86
+ else
87
+ chars.each do |char|
88
+ press char
89
+ sleep delay
90
+ end
91
+ end
80
92
  end
81
93
  else
82
94
  press sequence
@@ -91,7 +103,7 @@ module Capybara::Apparition
91
103
  end
92
104
 
93
105
  def release_pressed_keys
94
- @pressed_keys.values.each { |desc| up(desc) }
106
+ @pressed_keys.each_value { |desc| up(desc) }
95
107
  end
96
108
 
97
109
  def key_description(key)
@@ -9,12 +9,13 @@ module Capybara::Apparition
9
9
  @current_buttons = BUTTONS[:none]
10
10
  end
11
11
 
12
- def click_at(x:, y:, button: 'left', count: 1, modifiers: [])
12
+ def click_at(x:, y:, button: 'left', count: 1, delay: 0, modifiers: [])
13
13
  move_to x: x, y: y
14
14
  count.times do |num|
15
15
  @keyboard.with_keys(modifiers) do
16
16
  mouse_params = { x: x, y: y, button: button, count: num + 1 }
17
17
  down(**mouse_params)
18
+ sleep(delay || 0)
18
19
  up(**mouse_params)
19
20
  end
20
21
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Apparition
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apparition
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas Walpole
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-01-27 00:00:00.000000000 Z
11
+ date: 2020-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: capybara
@@ -44,6 +44,20 @@ dependencies:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
46
  version: 0.6.5
47
+ - !ruby/object:Gem::Dependency
48
+ name: byebug
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
47
61
  - !ruby/object:Gem::Dependency
48
62
  name: chunky_png
49
63
  requirement: !ruby/object:Gem::Requirement
@@ -242,6 +256,9 @@ files:
242
256
  - lib/capybara/apparition/browser/cookie.rb
243
257
  - lib/capybara/apparition/browser/frame.rb
244
258
  - lib/capybara/apparition/browser/header.rb
259
+ - lib/capybara/apparition/browser/launcher.rb
260
+ - lib/capybara/apparition/browser/launcher/local.rb
261
+ - lib/capybara/apparition/browser/launcher/remote.rb
245
262
  - lib/capybara/apparition/browser/modal.rb
246
263
  - lib/capybara/apparition/browser/page_manager.rb
247
264
  - lib/capybara/apparition/browser/render.rb
@@ -254,7 +271,6 @@ files:
254
271
  - lib/capybara/apparition/dev_tools_protocol/session.rb
255
272
  - lib/capybara/apparition/driver.rb
256
273
  - lib/capybara/apparition/driver/chrome_client.rb
257
- - lib/capybara/apparition/driver/launcher.rb
258
274
  - lib/capybara/apparition/driver/response.rb
259
275
  - lib/capybara/apparition/driver/web_socket_client.rb
260
276
  - lib/capybara/apparition/errors.rb
@@ -284,7 +300,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
284
300
  requirements:
285
301
  - - ">="
286
302
  - !ruby/object:Gem::Version
287
- version: 2.4.0
303
+ version: 2.5.0
288
304
  required_rubygems_version: !ruby/object:Gem::Requirement
289
305
  requirements:
290
306
  - - ">="
@@ -1,213 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'open3'
4
-
5
- module Capybara::Apparition
6
- class Browser
7
- class Launcher
8
- KILL_TIMEOUT = 5
9
-
10
- def self.start(*args, **options)
11
- new(*args, **options).tap(&:start)
12
- end
13
-
14
- def self.process_killer(pid)
15
- proc do
16
- begin
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
- end
36
-
37
- def initialize(headless:, **options)
38
- @path = ENV['BROWSER_PATH']
39
- @options = DEFAULT_OPTIONS.merge(options[:browser_options] || {})
40
- if headless
41
- @options.merge!(HEADLESS_OPTIONS)
42
- @options['disable-gpu'] = nil if Capybara::Apparition.windows?
43
- end
44
- @options['user-data-dir'] = Dir.mktmpdir
45
- end
46
-
47
- def start
48
- @output = Queue.new
49
-
50
- process_options = {}
51
- process_options[:pgroup] = true unless Capybara::Apparition.windows?
52
- cmd = [path] + @options.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
53
-
54
- stdin, @stdout_stderr, wait_thr = Open3.popen2e(*cmd, process_options)
55
- stdin.close
56
-
57
- @pid = wait_thr.pid
58
-
59
- @out_thread = Thread.new do
60
- while !@stdout_stderr.eof? && (data = @stdout_stderr.readpartial(512))
61
- @output << data
62
- end
63
- end
64
-
65
- ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
66
- end
67
-
68
- def stop
69
- return unless @pid
70
-
71
- kill
72
- ObjectSpace.undefine_finalizer(self)
73
- end
74
-
75
- def restart
76
- stop
77
- start
78
- end
79
-
80
- def host
81
- @host ||= ws_url.host
82
- end
83
-
84
- def port
85
- @port ||= ws_url.port
86
- end
87
-
88
- def ws_url
89
- @ws_url ||= begin
90
- regexp = %r{DevTools listening on (ws://.*)}
91
- url = nil
92
-
93
- sleep 3
94
- loop do
95
- break if (url = @output.pop.scan(regexp)[0])
96
- end
97
- @out_thread.kill
98
- @out_thread.join # wait for thread to end before closing io
99
- close_io
100
- Addressable::URI.parse(url[0])
101
- end
102
- end
103
-
104
- private
105
-
106
- def kill
107
- self.class.process_killer(@pid).call
108
- @pid = nil
109
- end
110
-
111
- def close_io
112
- @stdout_stderr.close unless @stdout_stderr.closed?
113
- rescue IOError
114
- raise unless RUBY_ENGINE == 'jruby'
115
- end
116
-
117
- def path
118
- host_os = RbConfig::CONFIG['host_os']
119
- @path ||= case RbConfig::CONFIG['host_os']
120
- when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
121
- windows_path
122
- when /darwin|mac os/
123
- macosx_path
124
- when /linux|solaris|bsd/
125
- linux_path
126
- else
127
- raise ArgumentError, "unknown os: #{host_os.inspect}"
128
- end
129
-
130
- raise ArgumentError, 'Unable to find Chrome executeable' unless File.file?(@path.to_s) && File.executable?(@path.to_s)
131
-
132
- @path
133
- end
134
-
135
- def windows_path
136
- envs = %w[LOCALAPPDATA PROGRAMFILES PROGRAMFILES(X86)]
137
- directories = %w[\\Google\\Chrome\\Application \\Chromium\\Application]
138
- files = %w[chrome.exe]
139
-
140
- directories.product(envs, files).lazy.map { |(dir, env, file)| "#{ENV[env]}\\#{dir}\\#{file}" }
141
- .find { |f| File.exist?(f) } || find_first_binary(*files)
142
- end
143
-
144
- def macosx_path
145
- directories = ['', File.expand_path('~')]
146
- files = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
147
- '/Applications/Chromium.app/Contents/MacOS/Chromium']
148
- directories.product(files).map(&:join).find { |f| File.exist?(f) } ||
149
- find_first_binary('Google Chrome', 'Chromium')
150
- end
151
-
152
- def linux_path
153
- directories = %w[/usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin /opt/google/chrome]
154
- files = %w[google-chrome chrome chromium chromium-browser]
155
-
156
- directories.product(files).map { |p| p.join('/') }.find { |f| File.exist?(f) } ||
157
- find_first_binary(*files)
158
- end
159
-
160
- def find_first_binary(*binaries)
161
- paths = ENV['PATH'].split(File::PATH_SEPARATOR)
162
-
163
- binaries.product(paths).lazy.map do |(binary, path)|
164
- Dir.glob(File.join(path, binary)).find { |f| File.executable?(f) }
165
- end.reject(&:nil?).first
166
- end
167
-
168
- # Chromium command line options
169
- # https://peter.sh/experiments/chromium-command-line-switches/
170
- DEFAULT_BOOLEAN_OPTIONS = %w[
171
- disable-background-networking
172
- disable-background-timer-throttling
173
- disable-breakpad
174
- disable-client-side-phishing-detection
175
- disable-default-apps
176
- disable-dev-shm-usage
177
- disable-extensions
178
- disable-features=site-per-process
179
- disable-hang-monitor
180
- disable-infobars
181
- disable-popup-blocking
182
- disable-prompt-on-repost
183
- disable-sync
184
- disable-translate
185
- disable-session-crashed-bubble
186
- metrics-recording-only
187
- no-first-run
188
- safebrowsing-disable-auto-update
189
- enable-automation
190
- password-store=basic
191
- use-mock-keychain
192
- keep-alive-for-test
193
- ].freeze
194
- # Note: --no-sandbox is not needed if you properly setup a user in the container.
195
- # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
196
- # no-sandbox
197
- # disable-web-security
198
- DEFAULT_VALUE_OPTIONS = {
199
- 'window-size' => '1024,768',
200
- 'homepage' => 'about:blank',
201
- 'remote-debugging-address' => '127.0.0.1'
202
- }.freeze
203
- DEFAULT_OPTIONS = DEFAULT_BOOLEAN_OPTIONS.each_with_object({}) { |opt, hsh| hsh[opt] = nil }
204
- .merge(DEFAULT_VALUE_OPTIONS)
205
- .freeze
206
- HEADLESS_OPTIONS = {
207
- 'headless' => nil,
208
- 'hide-scrollbars' => nil,
209
- 'mute-audio' => nil
210
- }.freeze
211
- end
212
- end
213
- end