apparition 0.5.0 → 0.6.0

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