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
@@ -2,26 +2,29 @@
2
2
 
3
3
  module Capybara::Apparition
4
4
  module Drag
5
- def drag_to(other, delay: 0.1)
6
- return html5_drag_to(other) if html5_draggable?
5
+ def drag_to(other, delay: 0.1, html5: nil, drop_modifiers: [])
6
+ drop_modifiers = Array(drop_modifiers)
7
7
 
8
- pos = visible_center
9
- raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if pos.nil?
10
-
11
- test = mouse_event_test(pos)
12
- raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test.selector, pos]) unless test.success
13
-
14
- begin
15
- @page.mouse.move_to(pos).down
16
- sleep delay
17
-
18
- other_pos = other.visible_center
19
- raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if other_pos.nil?
20
-
21
- @page.mouse.move_to(other_pos.merge(button: 'left'))
22
- sleep delay
23
- ensure
24
- @page.mouse.up
8
+ driver.execute_script MOUSEDOWN_TRACKER
9
+ scroll_if_needed
10
+ m = @page.mouse
11
+ m.move_to(**visible_center)
12
+ sleep delay
13
+ m.down
14
+ html5 = !driver.evaluate_script(LEGACY_DRAG_CHECK, self) if html5.nil?
15
+ if html5
16
+ driver.execute_script HTML5_DRAG_DROP_SCRIPT, self, other, delay, drop_modifiers
17
+ m.up(**other.visible_center)
18
+ else
19
+ @page.keyboard.with_keys(drop_modifiers) do
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
27
+ end
25
28
  end
26
29
  end
27
30
 
@@ -30,43 +33,109 @@ module Capybara::Apparition
30
33
  raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['hover']) if pos.nil?
31
34
 
32
35
  other_pos = { x: pos[:x] + x, y: pos[:y] + y }
33
- raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test['selector'], pos]) unless mouse_event_test?(pos)
36
+ raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test['selector'], pos]) unless mouse_event_test?(**pos)
34
37
 
35
- @page.mouse.move_to(pos).down
38
+ @page.mouse.move_to(**pos).down
36
39
  sleep delay
37
- @page.mouse.move_to(other_pos.merge(button: 'left'))
40
+ @page.mouse.move_to(**other_pos)
38
41
  sleep delay
39
42
  @page.mouse.up
40
43
  end
41
44
 
42
- private
43
-
44
- def html5_drag_to(element)
45
- driver.execute_script MOUSEDOWN_TRACKER
46
- scroll_if_needed
47
- @page.mouse.move_to(visible_center).down
48
- if driver.evaluate_script('window.capybara_mousedown_prevented')
49
- element.scroll_if_needed
50
- @page.mouse.move_to(element.visible_center).up
45
+ def drop(*args)
46
+ if args[0].is_a? String
47
+ input = evaluate_on ATTACH_FILE
48
+ tag_name = input['description'].split(/[.#]/, 2)[0]
49
+ input = Capybara::Apparition::Node.new(driver, @page, input['objectId'], tag_name: tag_name)
50
+ input.set(args)
51
+ evaluate_on DROP_FILE, objectId: input.id
51
52
  else
52
- driver.execute_script HTML5_DRAG_DROP_SCRIPT, self, element
53
- @page.mouse.up(element.visible_center)
53
+ items = args.each_with_object([]) do |arg, arr|
54
+ arg.each_with_object(arr) do |(type, data), arr_|
55
+ arr_ << { type: type, data: data }
56
+ end
57
+ end
58
+ evaluate_on DROP_STRING, value: items
54
59
  end
55
60
  end
56
61
 
57
- def html5_draggable?
58
- native.property('draggable')
59
- end
62
+ DROP_STRING = <<~JS
63
+ function(strings){
64
+ var dt = new DataTransfer(),
65
+ opts = { cancelable: true, bubbles: true, dataTransfer: dt };
66
+ for (var i=0; i < strings.length; i++){
67
+ if (dt.items) {
68
+ dt.items.add(strings[i]['data'], strings[i]['type']);
69
+ } else {
70
+ dt.setData(strings[i]['type'], strings[i]['data']);
71
+ }
72
+ }
73
+ var dropEvent = new DragEvent('drop', opts);
74
+ this.dispatchEvent(dropEvent);
75
+ }
76
+ JS
77
+
78
+ DROP_FILE = <<~JS
79
+ function(input){
80
+ var files = input.files,
81
+ dt = new DataTransfer(),
82
+ opts = { cancelable: true, bubbles: true, dataTransfer: dt };
83
+ input.parentElement.removeChild(input);
84
+ if (dt.items){
85
+ for (var i=0; i<files.length; i++){
86
+ dt.items.add(files[i]);
87
+ }
88
+ } else {
89
+ Object.defineProperty(dt, "files", {
90
+ value: files,
91
+ writable: false
92
+ });
93
+ }
94
+ var dropEvent = new DragEvent('drop', opts);
95
+ this.dispatchEvent(dropEvent);
96
+ }
97
+ JS
98
+
99
+ ATTACH_FILE = <<~JS
100
+ function(){
101
+ var input = document.createElement('INPUT');
102
+ input.type = "file";
103
+ input.id = "_capybara_drop_file";
104
+ input.multiple = true;
105
+ document.body.appendChild(input);
106
+ return input;
107
+ }
108
+ JS
60
109
 
61
110
  MOUSEDOWN_TRACKER = <<~JS
111
+ window.capybara_mousedown_prevented = null;
62
112
  document.addEventListener('mousedown', ev => {
63
113
  window.capybara_mousedown_prevented = ev.defaultPrevented;
64
114
  }, { once: true, passive: true })
65
115
  JS
66
116
 
117
+ LEGACY_DRAG_CHECK = <<~JS
118
+ (function(el){
119
+ if ([true, null].includes(window.capybara_mousedown_prevented)){
120
+ return true;
121
+ }
122
+ do {
123
+ if (el.draggable) return false;
124
+ } while (el = el.parentElement );
125
+ return true;
126
+ })(arguments[0])
127
+ JS
128
+
67
129
  HTML5_DRAG_DROP_SCRIPT = <<~JS
68
- var source = arguments[0];
69
- var target = arguments[1];
130
+ let source = arguments[0];
131
+ const target = arguments[1];
132
+ const step_delay = arguments[2] * 1000;
133
+ const drop_modifiers = arguments[3];
134
+ const key_aliases = {
135
+ 'cmd': 'meta',
136
+ 'command': 'meta',
137
+ 'control': 'ctrl',
138
+ };
70
139
 
71
140
  function rectCenter(rect){
72
141
  return new DOMPoint(
@@ -106,8 +175,63 @@ module Capybara::Apparition
106
175
  return new DOMPoint(pt.x,pt.y);
107
176
  }
108
177
 
109
- var dt = new DataTransfer();
110
- var opts = { cancelable: true, bubbles: true, dataTransfer: dt };
178
+ function dragStart() {
179
+ return new Promise( resolve => {
180
+ var dragEvent = new DragEvent('dragstart', opts);
181
+ source.dispatchEvent(dragEvent);
182
+ setTimeout(resolve, step_delay)
183
+ })
184
+ }
185
+
186
+ function dragEnter() {
187
+ return new Promise( resolve => {
188
+ target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
189
+ let targetRect = target.getBoundingClientRect(),
190
+ sourceCenter = rectCenter(source.getBoundingClientRect());
191
+
192
+ drop_modifiers.map(key => key_aliases[key] || key)
193
+ .forEach(key => opts[key + 'Key'] = true);
194
+
195
+ // fire 2 dragover events to simulate dragging with a direction
196
+ let entryPoint = pointOnRect(sourceCenter, targetRect);
197
+ let dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts);
198
+ let dragOverEvent = new DragEvent('dragover', dragOverOpts);
199
+ target.dispatchEvent(dragOverEvent);
200
+ setTimeout(resolve, step_delay)
201
+ })
202
+ }
203
+
204
+ function dragOnto() {
205
+ return new Promise( resolve => {
206
+ var targetCenter = rectCenter(target.getBoundingClientRect());
207
+ dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
208
+ dragOverEvent = new DragEvent('dragover', dragOverOpts);
209
+ target.dispatchEvent(dragOverEvent);
210
+ setTimeout(resolve, step_delay, { drop: dragOverEvent.defaultPrevented, opts: dragOverOpts});
211
+ })
212
+ }
213
+
214
+ function dragLeave({ drop, opts: dragOverOpts }) {
215
+ return new Promise( resolve => {
216
+ var dragLeaveOptions = { ...opts, ...dragOverOpts };
217
+ var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions);
218
+ target.dispatchEvent(dragLeaveEvent);
219
+ if (drop) {
220
+ var dropEvent = new DragEvent('drop', dragLeaveOptions);
221
+ target.dispatchEvent(dropEvent);
222
+ }
223
+ var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
224
+ source.dispatchEvent(dragEndEvent);
225
+ setTimeout(resolve, step_delay);
226
+ })
227
+ }
228
+
229
+ const dt = new DataTransfer();
230
+ const opts = { cancelable: true, bubbles: true, dataTransfer: dt };
231
+
232
+ while (source && !source.draggable) {
233
+ source = source.parentElement;
234
+ }
111
235
 
112
236
  if (source.tagName == 'A'){
113
237
  dt.setData('text/uri-list', source.href);
@@ -118,31 +242,7 @@ module Capybara::Apparition
118
242
  dt.setData('text', source.src);
119
243
  }
120
244
 
121
- var dragEvent = new DragEvent('dragstart', opts);
122
- source.dispatchEvent(dragEvent);
123
- target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
124
- var targetRect = target.getBoundingClientRect();
125
- var sourceCenter = rectCenter(source.getBoundingClientRect());
126
-
127
- // fire 2 dragover events to simulate dragging with a direction
128
- var entryPoint = pointOnRect(sourceCenter, targetRect)
129
- var dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts);
130
- var dragOverEvent = new DragEvent('dragover', dragOverOpts);
131
- target.dispatchEvent(dragOverEvent);
132
-
133
- var targetCenter = rectCenter(targetRect);
134
- dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
135
- dragOverEvent = new DragEvent('dragover', dragOverOpts);
136
- target.dispatchEvent(dragOverEvent);
137
-
138
- var dragLeaveEvent = new DragEvent('dragleave', opts);
139
- target.dispatchEvent(dragLeaveEvent);
140
- if (dragOverEvent.defaultPrevented) {
141
- var dropEvent = new DragEvent('drop', opts);
142
- target.dispatchEvent(dropEvent);
143
- }
144
- var dragEndEvent = new DragEvent('dragend', opts);
145
- source.dispatchEvent(dragEndEvent);
245
+ dragStart().then(dragEnter).then(dragOnto).then(dragLeave)
146
246
  JS
147
247
  end
148
248
  end
@@ -10,44 +10,43 @@ module Capybara::Apparition
10
10
  attr_reader :modal_messages
11
11
  attr_reader :mouse, :keyboard
12
12
  attr_reader :viewport_size
13
+ attr_reader :browser_context_id
13
14
  attr_accessor :perm_headers, :temp_headers, :temp_no_redirect_headers
14
15
  attr_reader :network_traffic
16
+ attr_reader :target_id
15
17
 
16
- def self.create(browser, session, id, ignore_https_errors: false, screenshot_task_queue: nil, js_errors: false)
17
- session.command 'Page.enable'
18
+ def self.create(browser, session, id, browser_context_id,
19
+ ignore_https_errors: false, **options)
20
+ session.async_command 'Page.enable'
18
21
 
19
22
  # Provides a lot of info - but huge overhead
20
23
  # session.command 'Page.setLifecycleEventsEnabled', enabled: true
21
24
 
22
- page = Page.new(browser, session, id, ignore_https_errors, screenshot_task_queue, js_errors)
25
+ page = Page.new(browser, session, id, browser_context_id, **options)
23
26
 
24
27
  session.async_commands 'Network.enable', 'Runtime.enable', 'Security.enable', 'DOM.enable'
25
- # session.command 'Network.enable'
26
- # session.command 'Runtime.enable'
27
- # session.command 'Security.enable'
28
- # session.command 'Security.setOverrideCertificateErrors', override: true if ignore_https_errors
29
- session.command 'Security.setIgnoreCertificateErrors', ignore: !!ignore_https_errors
30
- # session.command 'DOM.enable'
31
- # session.command 'Log.enable'
28
+ session.async_command 'Security.setIgnoreCertificateErrors', ignore: !!ignore_https_errors
32
29
  if Capybara.save_path
33
- session.command 'Page.setDownloadBehavior', behavior: 'allow', downloadPath: Capybara.save_path
30
+ session.async_command 'Page.setDownloadBehavior', behavior: 'allow', downloadPath: Capybara.save_path
34
31
  end
35
32
  page
36
33
  end
37
34
 
38
- def initialize(browser, session, id, _ignore_https_errors, _screenshot_task_queue, js_errors)
39
- @target_id = id
35
+ def initialize(browser, session, target_id, browser_context_id,
36
+ js_errors: false, url_blacklist: [], url_whitelist: [], extensions: [])
37
+ @target_id = target_id
38
+ @browser_context_id = browser_context_id
40
39
  @browser = browser
41
40
  @session = session
42
41
  @keyboard = Keyboard.new(self)
43
42
  @mouse = Mouse.new(self, @keyboard)
44
43
  @modals = []
45
44
  @modal_messages = []
46
- @frames = Capybara::Apparition::FrameManager.new(id)
45
+ @frames = Capybara::Apparition::FrameManager.new(@target_id)
47
46
  @response_headers = {}
48
47
  @status_code = 0
49
- @url_blacklist = []
50
- @url_whitelist = []
48
+ @url_blacklist = url_blacklist || []
49
+ @url_whitelist = url_whitelist || []
51
50
  @credentials = nil
52
51
  @auth_attempts = []
53
52
  @proxy_credentials = nil
@@ -67,6 +66,10 @@ module Capybara::Apparition
67
66
 
68
67
  register_js_error_handler # if js_errors
69
68
 
69
+ extensions.each do |name|
70
+ add_extension(name)
71
+ end
72
+
70
73
  setup_network_interception if browser.proxy_auth
71
74
  end
72
75
 
@@ -84,6 +87,12 @@ module Capybara::Apparition
84
87
  @perm_headers = {}
85
88
  end
86
89
 
90
+ def add_extension(filename)
91
+ command('Page.addScriptToEvaluateOnNewDocument', source: File.read(filename))
92
+ rescue Errno::ENOENT
93
+ raise ::Capybara::Apparition::BrowserError.new('name' => "Unable to load extension: #{filename}", 'args' => nil)
94
+ end
95
+
87
96
  def add_modal(modal_response)
88
97
  @last_modal_message = nil
89
98
  @modals.push(modal_response)
@@ -136,11 +145,12 @@ module Capybara::Apparition
136
145
  pixel_ratio = evaluate('window.devicePixelRatio')
137
146
  scale = (@browser.zoom_factor || 1).to_f / pixel_ratio
138
147
  if options[:format].to_s == 'pdf'
139
- params = {}
140
- params[:paperWidth] = @browser.paper_size[:width].to_f if @browser.paper_size
141
- params[:paperHeight] = @browser.paper_size[:height].to_f if @browser.paper_size
142
- params[:scale] = scale
143
- command('Page.printToPDF', params)
148
+ params = { scale: scale }
149
+ if @browser.paper_size
150
+ params[:paperWidth] = @browser.paper_size[:width].to_f
151
+ params[:paperHeight] = @browser.paper_size[:height].to_f
152
+ end
153
+ command('Page.printToPDF', **params)
144
154
  else
145
155
  clip_options = if options[:selector]
146
156
  pos = evaluate("document.querySelector('#{options.delete(:selector)}').getBoundingClientRect().toJSON();")
@@ -155,7 +165,7 @@ module Capybara::Apparition
155
165
  JS
156
166
  end
157
167
  options[:clip] = { x: 0, y: 0, scale: scale }.merge(clip_options)
158
- command('Page.captureScreenshot', options)
168
+ command('Page.captureScreenshot', **options)
159
169
  end['data']
160
170
  end
161
171
 
@@ -164,15 +174,14 @@ module Capybara::Apparition
164
174
  frame_id = node['node']['frameId']
165
175
 
166
176
  timer = Capybara::Helpers.timer(expire_in: 10)
167
- while (frame = @frames.get(frame_id)).nil? || frame.loading?
177
+ while (frame = @frames[frame_id]).nil? || frame.loading?
168
178
  # Wait for the frame creation messages to be processed
169
179
  if timer.expired?
170
- puts 'Timed out waiting from frame to be ready'
180
+ puts 'Timed out waiting for frame to be ready'
171
181
  raise TimeoutError.new('push_frame')
172
182
  end
173
183
  sleep 0.1
174
184
  end
175
- return unless frame
176
185
 
177
186
  frame.element_id = frame_el.base.id
178
187
  @frames.push_frame(frame.id)
@@ -187,12 +196,12 @@ module Capybara::Apparition
187
196
  wait_for_loaded
188
197
  js_escaped_selector = selector.gsub('\\', '\\\\\\').gsub('"', '\"')
189
198
  query = method == :css ? CSS_FIND_JS : XPATH_FIND_JS
190
- result = _raw_evaluate(query % js_escaped_selector)
191
- (result || []).map { |r_o| [self, r_o['objectId']] }
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]] }
192
201
  rescue ::Capybara::Apparition::BrowserError => e
193
- raise unless e.name =~ /is not a valid (XPath expression|selector)/
202
+ raise unless /is not a valid (XPath expression|selector)/.match? e.name
194
203
 
195
- raise Capybara::Apparition::InvalidSelector, [method, selector]
204
+ raise Capybara::Apparition::InvalidSelector, 'args' => [method, selector]
196
205
  end
197
206
 
198
207
  def execute(script, *args)
@@ -225,20 +234,36 @@ module Capybara::Apparition
225
234
  go_history(+1)
226
235
  end
227
236
 
228
- attr_reader :response_headers
237
+ def response_headers
238
+ @response_headers[current_frame.id] || {}
239
+ end
229
240
 
230
241
  attr_reader :status_code
231
242
 
232
243
  def wait_for_loaded(allow_obsolete: false)
233
- timer = Capybara::Helpers.timer(expire_in: 10)
234
- cf = current_frame
235
- until cf.usable? || (allow_obsolete && cf.obsolete?) || @js_error
236
- if timer.expired?
237
- puts 'Timedout waiting for page to be loaded'
238
- raise TimeoutError.new('wait_for_loaded')
244
+ # We can't reliably detect if the page is loaded, so just ensure the context
245
+ # is usable
246
+ timer = Capybara::Helpers.timer(expire_in: 30)
247
+ page_function = '(function(){ return 1 == 1; })()'
248
+ begin
249
+ response = command('Runtime.evaluate',
250
+ expression: page_function,
251
+ contextId: current_frame.context_id,
252
+ returnByValue: false,
253
+ awaitPromise: true)
254
+ process_response(response)
255
+ current_frame.loaded!
256
+ rescue # rubocop:disable Style/RescueStandardError
257
+ return if allow_obsolete && current_frame.obsolete?
258
+
259
+ unless timer.expired?
260
+ sleep 0.05
261
+ retry
239
262
  end
240
- sleep 0.05
263
+ puts 'Timedout waiting for page to be loaded' if ENV['DEBUG']
264
+ raise TimeoutError.new('wait_for_loaded')
241
265
  end
266
+
242
267
  raise JavascriptError.new(js_error) if @js_error
243
268
  end
244
269
 
@@ -259,8 +284,7 @@ module Capybara::Apparition
259
284
  @status_code = 0
260
285
  navigate_opts = { url: url, transitionType: 'reload' }
261
286
  navigate_opts[:referrer] = extra_headers['Referer'] if extra_headers['Referer']
262
- response = command('Page.navigate', navigate_opts)
263
-
287
+ response = command('Page.navigate', **navigate_opts)
264
288
  raise StatusFailError, 'args' => [url, response['errorText']] if response['errorText']
265
289
 
266
290
  main_frame.loading(response['loaderId'])
@@ -276,7 +300,7 @@ module Capybara::Apparition
276
300
 
277
301
  def element_from_point(x:, y:)
278
302
  r_o = _raw_evaluate("document.elementFromPoint(#{x}, #{y})", context_id: main_frame.context_id)
279
- while r_o && (r_o['description'] =~ /^iframe/)
303
+ while r_o&.[]('description')&.start_with?('iframe')
280
304
  frame_node = command('DOM.describeNode', objectId: r_o['objectId'])
281
305
  frame = @frames.get(frame_node.dig('node', 'frameId'))
282
306
  fo = frame_offset(frame)
@@ -291,7 +315,7 @@ module Capybara::Apparition
291
315
  end
292
316
 
293
317
  def set_viewport(width:, height:, screen: nil)
294
- wait_for_loaded
318
+ # wait_for_loaded
295
319
  @viewport_size = { width: width, height: height }
296
320
  result = @browser.command('Browser.getWindowForTarget', targetId: @target_id)
297
321
  begin
@@ -311,7 +335,7 @@ module Capybara::Apparition
311
335
  }
312
336
  metrics[:screenWidth], metrics[:screenHeight] = *screen if screen
313
337
 
314
- command('Emulation.setDeviceMetricsOverride', metrics)
338
+ command('Emulation.setDeviceMetricsOverride', **metrics)
315
339
  end
316
340
 
317
341
  def fullscreen
@@ -350,11 +374,9 @@ module Capybara::Apparition
350
374
  end
351
375
 
352
376
  def update_headers(async: false)
353
- method = async ? :async_command : :command
354
- if (ua = extra_headers.find { |k, _v| k =~ /^User-Agent$/i })
355
- send(method, 'Network.setUserAgentOverride', userAgent: ua[1])
377
+ if (ua = extra_headers.find { |k, _v| /^User-Agent$/i.match? k })
378
+ send(async ? :async_command : :command, 'Network.setUserAgentOverride', userAgent: ua[1])
356
379
  end
357
- send(method, 'Network.setExtraHTTPHeaders', headers: extra_headers)
358
380
  setup_network_interception
359
381
  end
360
382
 
@@ -362,7 +384,7 @@ module Capybara::Apparition
362
384
  if page
363
385
  self.url_whitelist = page.url_whitelist.dup
364
386
  self.url_blacklist = page.url_blacklist.dup
365
- set_viewport(page.viewport_size) if page.viewport_size
387
+ set_viewport(**page.viewport_size) if page.viewport_size
366
388
  end
367
389
  self
368
390
  end
@@ -377,6 +399,14 @@ module Capybara::Apparition
377
399
 
378
400
  attr_reader :url_blacklist, :url_whitelist
379
401
 
402
+ def current_frame
403
+ @frames.current
404
+ end
405
+
406
+ def main_frame
407
+ @frames.main
408
+ end
409
+
380
410
  private
381
411
 
382
412
  def eval_wrapped_script(wrapper, script, args)
@@ -393,9 +423,9 @@ module Capybara::Apparition
393
423
  end
394
424
 
395
425
  def register_event_handlers
396
- @session.on 'Page.javascriptDialogOpening' do |params|
397
- type = params['type'].to_sym
398
- accept = accept_modal?(type, message: params['message'], manual: params['hasBrowserHandler'])
426
+ @session.on 'Page.javascriptDialogOpening' do |type:, message:, has_browser_handler:, **params|
427
+ type = type.to_sym
428
+ accept = accept_modal?(type, message: message, manual: has_browser_handler)
399
429
  next if accept.nil?
400
430
 
401
431
  if type == :prompt
@@ -403,7 +433,7 @@ module Capybara::Apparition
403
433
  when false
404
434
  async_command('Page.handleJavaScriptDialog', accept: false)
405
435
  when true
406
- async_command('Page.handleJavaScriptDialog', accept: true, promptText: params['defaultPrompt'])
436
+ async_command('Page.handleJavaScriptDialog', accept: true, promptText: params[:default_prompt])
407
437
  else
408
438
  async_command('Page.handleJavaScriptDialog', accept: true, promptText: accept)
409
439
  end
@@ -418,37 +448,38 @@ module Capybara::Apparition
418
448
  end
419
449
  end
420
450
 
421
- @session.on 'Page.windowOpen' do |params|
451
+ @session.on 'Page.windowOpen' do |**params|
422
452
  puts "**** windowOpen was called with: #{params}" if ENV['DEBUG']
423
- # TODO: find a better way to handle this
424
- sleep 0.4 # wait a bit so the window has time to start loading
453
+ @browser.refresh_pages(opener: self)
425
454
  end
426
455
 
427
- @session.on 'Page.frameAttached' do |params|
456
+ @session.on 'Page.frameAttached' do |**params|
428
457
  puts "**** frameAttached called with #{params}" if ENV['DEBUG']
429
458
  # @frames.get(params["frameId"]) = Frame.new(params)
430
459
  end
431
460
 
432
- @session.on 'Page.frameDetached' do |params|
433
- @frames.delete(params['frameId'])
434
- puts "**** frameDetached called with #{params}" if ENV['DEBUG']
461
+ @session.on 'Page.frameDetached' do |frame_id:, **params|
462
+ @frames.delete(frame_id)
463
+ puts "**** frameDetached called with #{frame_id} : #{params}" if ENV['DEBUG']
435
464
  end
436
465
 
437
- @session.on 'Page.frameNavigated' do |params|
438
- puts "**** frameNavigated called with #{params}" if ENV['DEBUG']
439
- frame_params = params['frame']
440
- unless @frames.exists?(frame_params['id'])
441
- puts "**** creating frome for #{frame_params['id']}" if ENV['DEBUG']
442
- @frames.add(frame_params['id'], frame_params)
466
+ @session.on 'Page.frameNavigated' do |frame:|
467
+ puts "**** frameNavigated called with #{frame}" if ENV['DEBUG']
468
+ unless @frames.exists?(frame['id'])
469
+ puts "**** creating frame for #{frame['id']}" if ENV['DEBUG']
470
+ @frames.add(frame['id'], frame)
443
471
  end
472
+ @frames.get(frame['id'])&.loading(frame['loaderId'] || -1)
444
473
  end
445
474
 
446
- @session.on 'Page.frameStartedLoading' do |params|
447
- @frames.get(params['frameId'])&.loading(-1)
475
+ @session.on 'Page.frameStartedLoading' do |frame_id:|
476
+ puts "Setting loading for #{frame_id}" if ENV['DEBUG']
477
+ @frames.get(frame_id)&.loading(-1)
448
478
  end
449
479
 
450
- @session.on 'Page.frameStoppedLoading' do |params|
451
- @frames.get(params['frameId'])&.loaded!
480
+ @session.on 'Page.frameStoppedLoading' do |frame_id:|
481
+ puts "Setting loaded for #{frame_id}" if ENV['DEBUG']
482
+ @frames.get(frame_id)&.loaded!
452
483
  end
453
484
 
454
485
  # @session.on 'Page.lifecycleEvent' do |params|
@@ -470,16 +501,12 @@ module Capybara::Apparition
470
501
  main_frame.loaded! if @status_code != 200
471
502
  end
472
503
 
473
- @session.on 'Page.navigatedWithinDocument' do |params|
474
- puts "**** navigatedWithinDocument called with #{params}" if ENV['DEBUG']
475
- frame_id = params['frameId']
476
- # @frames.get(frame_id).state = :loaded if frame_id == main_frame.id
504
+ @session.on 'Page.navigatedWithinDocument' do |frame_id:, **params|
505
+ puts "**** navigatedWithinDocument called with #{frame_id}: #{params}" if ENV['DEBUG']
477
506
  @frames.get(frame_id).loaded! if frame_id == main_frame.id
478
507
  end
479
508
 
480
- @session.on 'Runtime.executionContextCreated' do |params|
481
- puts "**** executionContextCreated: #{params}" if ENV['DEBUG']
482
- context = params['context']
509
+ @session.on 'Runtime.executionContextCreated' do |context:|
483
510
  frame_id = context.dig('auxData', 'frameId')
484
511
  if context.dig('auxData', 'isDefault') && frame_id
485
512
  if (frame = @frames.get(frame_id))
@@ -490,63 +517,78 @@ module Capybara::Apparition
490
517
  end
491
518
  end
492
519
 
493
- @session.on 'Runtime.executionContextDestroyed' do |params|
494
- puts "executionContextDestroyed: #{params}" if ENV['DEBUG']
495
- @frames.destroy_context(params['executionContextId'])
520
+ @session.on 'Runtime.executionContextDestroyed' do |execution_context_id:, **params|
521
+ puts "executionContextDestroyed: #{execution_context_id} : #{params}" if ENV['DEBUG']
522
+ @frames.destroy_context(execution_context_id)
496
523
  end
497
524
 
498
- @session.on 'Network.requestWillBeSent' do |params|
499
- @open_resource_requests[params['requestId']] = params.dig('request', 'url')
525
+ @session.on 'Network.requestWillBeSent' do |request_id:, request: nil, **|
526
+ @open_resource_requests[request_id] = request&.dig('url')
500
527
  end
501
528
 
502
- @session.on 'Network.responseReceived' do |params|
503
- @open_resource_requests.delete(params['requestId'])
529
+ @session.on 'Network.responseReceived' do |request_id:, **|
530
+ @open_resource_requests.delete(request_id)
504
531
  temp_headers.clear
505
532
  update_headers(async: true)
506
533
  end
507
534
 
508
- @session.on 'Network.requestWillBeSent' do |params|
535
+ @session.on 'Network.requestWillBeSent' do |**params|
509
536
  @network_traffic.push(NetworkTraffic::Request.new(params))
510
537
  end
511
538
 
512
- @session.on 'Network.responseReceived' do |params|
513
- req = @network_traffic.find { |request| request.request_id == params['requestId'] }
514
- req.response = NetworkTraffic::Response.new(params['response']) if req
539
+ @session.on 'Network.responseReceived' do |request_id:, response:, **|
540
+ req = @network_traffic.find { |request| request.request_id == request_id }
541
+ req.response = NetworkTraffic::Response.new(response) if req
515
542
  end
516
543
 
517
- @session.on 'Network.responseReceived' do |params|
518
- if params['type'] == 'Document'
519
- @response_headers = params['response']['headers']
520
- @status_code = params['response']['status']
544
+ @session.on 'Network.responseReceived' do |type:, frame_id: nil, response: nil, **|
545
+ if type == 'Document'
546
+ @response_headers[frame_id] = response['headers']
547
+ @status_code = response['status']
521
548
  end
522
549
  end
523
550
 
524
- @session.on 'Network.loadingFailed' do |params|
525
- req = @network_traffic.find { |request| request.request_id == params['requestId'] }
526
- req&.blocked_params = params if params['blockedReason']
527
- if params['type'] == 'Document'
528
- puts "Loading Failed - request: #{params['requestId']} : #{params['errorText']}" if ENV['DEBUG']
551
+ @session.on 'Network.loadingFailed' do |type:, request_id:, blocked_reason: nil, error_text: nil, **params|
552
+ req = @network_traffic.find { |request| request.request_id == request_id }
553
+ req&.blocked_params = params if blocked_reason
554
+ if type == 'Document'
555
+ puts "Loading Failed - request: #{request_id} : #{error_text}" if ENV['DEBUG']
529
556
  end
530
557
  end
531
558
 
532
- @session.on 'Network.requestIntercepted' do |params|
533
- request, interception_id = *params.values_at('request', 'interceptionId')
534
- if params['authChallenge']
535
- if params['authChallenge']['source'] == 'Proxy'
536
- handle_proxy_auth(interception_id)
559
+ @session.on 'Fetch.requestPaused' do |request:, request_id:, resource_type:, **|
560
+ process_intercepted_fetch(request_id, request, resource_type)
561
+ end
562
+
563
+ @session.on 'Fetch.authRequired' do |request_id:, auth_challenge: nil, **|
564
+ next unless auth_challenge
565
+
566
+ credentials_response = if auth_challenge['source'] == 'Proxy'
567
+ if @proxy_auth_attempts.include?(request_id)
568
+ puts 'Cancelling proxy auth' if ENV['DEBUG']
569
+ { response: 'CancelAuth' }
537
570
  else
538
- handle_user_auth(interception_id)
571
+ puts 'Replying with proxy auth credentials' if ENV['DEBUG']
572
+ @proxy_auth_attempts.push(request_id)
573
+ { response: 'ProvideCredentials' }.merge(@browser.proxy_auth || {})
539
574
  end
575
+ elsif @auth_attempts.include?(request_id)
576
+ puts 'Cancelling auth' if ENV['DEBUG']
577
+ { response: 'CancelAuth' }
540
578
  else
541
- process_intercepted_request(interception_id, request, params['isNavigationRequest'])
579
+ @auth_attempts.push(request_id)
580
+ puts 'Replying with auth credentials' if ENV['DEBUG']
581
+ { response: 'ProvideCredentials' }.merge(@credentials || {})
542
582
  end
583
+
584
+ async_command('Fetch.continueWithAuth', requestId: request_id, authChallengeResponse: credentials_response)
543
585
  end
544
586
 
545
- @session.on 'Runtime.consoleAPICalled' do |params|
587
+ @session.on 'Runtime.consoleAPICalled' do |**params|
546
588
  # {"type"=>"log", "args"=>[{"type"=>"string", "value"=>"hello"}], "executionContextId"=>2, "timestamp"=>1548722854903.285, "stackTrace"=>{"callFrames"=>[{"functionName"=>"", "scriptId"=>"15", "url"=>"http://127.0.0.1:53977/", "lineNumber"=>6, "columnNumber"=>22}]}}
547
- details = params.dig('stackTrace', 'callFrames')&.first
548
- @browser.console.log(params['type'],
549
- params['args'].map { |arg| arg['description'] || arg['value'] }.join(' ').to_s,
589
+ details = params.dig(:stack_trace, 'callFrames')&.first
590
+ @browser.console.log(params[:type],
591
+ params[:args].map { |arg| arg['description'] || arg['value'] }.join(' ').to_s,
550
592
  source: details['url'].empty? ? nil : details['url'],
551
593
  line_number: details['lineNumber'].zero? ? nil : details['lineNumber'],
552
594
  columnNumber: details['columnNumber'].zero? ? nil : details['columnNumber'])
@@ -565,15 +607,16 @@ module Capybara::Apparition
565
607
  end
566
608
 
567
609
  def register_js_error_handler
568
- @session.on 'Runtime.exceptionThrown' do |params|
569
- @js_error ||= params.dig('exceptionDetails', 'exception', 'description') if @raise_js_errors
610
+ @session.on 'Runtime.exceptionThrown' do |exception_details: nil, **|
611
+ @js_error ||= exception_details&.dig('exception', 'description') if @raise_js_errors
570
612
 
571
- details = params.dig('exceptionDetails', 'stackTrace', 'callFrames')&.first
613
+ details = exception_details&.dig('stackTrace', 'callFrames')&.first ||
614
+ exception_details || {}
572
615
  @browser.console.log('error',
573
- params.dig('exceptionDetails', 'exception', 'description'),
574
- source: details['url'].empty? ? nil : details['url'],
575
- line_number: details['lineNumber'].zero? ? nil : details['lineNumber'],
576
- columnNumber: details['columnNumber'].zero? ? nil : details['columnNumber'])
616
+ exception_details&.dig('exception', 'description'),
617
+ source: details['url'].to_s.empty? ? nil : details['url'],
618
+ line_number: details['lineNumber'].to_i.zero? ? nil : details['lineNumber'],
619
+ columnNumber: details['columnNumber'].to_i.zero? ? nil : details['columnNumber'])
577
620
  end
578
621
  end
579
622
 
@@ -584,11 +627,13 @@ module Capybara::Apparition
584
627
 
585
628
  def setup_network_interception
586
629
  async_command 'Network.setCacheDisabled', cacheDisabled: true
587
- async_command 'Network.setRequestInterception', patterns: [{ urlPattern: '*' }]
630
+ async_command 'Fetch.enable', handleAuthRequests: true
588
631
  end
589
632
 
590
- def process_intercepted_request(interception_id, request, navigation)
633
+ def process_intercepted_fetch(interception_id, request, resource_type)
634
+ navigation = (resource_type == 'Document')
591
635
  headers, url = request.values_at('headers', 'url')
636
+ headers = headers.merge(extra_headers)
592
637
 
593
638
  unless @temp_headers.empty? || navigation # rubocop:disable Style/IfUnlessModifier
594
639
  headers.delete_if { |name, value| @temp_headers[name] == value }
@@ -596,39 +641,27 @@ module Capybara::Apparition
596
641
  unless @temp_no_redirect_headers.empty? || !navigation
597
642
  headers.delete_if { |name, value| @temp_no_redirect_headers[name] == value }
598
643
  end
599
- if (accept = perm_headers.keys.find { |k| k =~ /accept/i })
644
+ if (accept = perm_headers.keys.find { |k| /accept/i.match? k })
600
645
  headers[accept] = perm_headers[accept]
601
646
  end
602
647
 
603
648
  if @url_blacklist.any? { |r| url.match Regexp.escape(r).gsub('\*', '.*?') }
604
- block_request(interception_id, 'Failed')
649
+ async_command('Fetch.failRequest', errorReason: 'Failed', requestId: interception_id)
605
650
  elsif @url_whitelist.any?
606
651
  if @url_whitelist.any? { |r| url.match Regexp.escape(r).gsub('\*', '.*?') }
607
- continue_request(interception_id, headers: headers)
652
+ async_command('Fetch.continueRequest',
653
+ requestId: interception_id,
654
+ headers: headers.map { |k, v| { name: k, value: v } })
608
655
  else
609
- block_request(interception_id, 'Failed')
656
+ async_command('Fetch.failRequest', errorReason: 'Failed', requestId: interception_id)
610
657
  end
611
658
  else
612
- continue_request(interception_id, headers: headers)
659
+ async_command('Fetch.continueRequest',
660
+ requestId: interception_id,
661
+ headers: headers.map { |k, v| { name: k, value: v } })
613
662
  end
614
663
  end
615
664
 
616
- def continue_request(id, **params)
617
- async_command 'Network.continueInterceptedRequest', interceptionId: id, **params
618
- end
619
-
620
- def block_request(id, reason)
621
- async_command 'Network.continueInterceptedRequest', errorReason: reason, interceptionId: id
622
- end
623
-
624
- def current_frame
625
- @frames.current
626
- end
627
-
628
- def main_frame
629
- @frames.main
630
- end
631
-
632
665
  def go_history(delta)
633
666
  history = command('Page.getNavigationHistory')
634
667
  entry = history['entries'][history['currentIndex'] + delta]
@@ -667,7 +700,8 @@ module Capybara::Apparition
667
700
  executionContextId: context_id,
668
701
  arguments: args,
669
702
  returnByValue: false,
670
- awaitPromise: true)
703
+ awaitPromise: true,
704
+ userGesture: true)
671
705
  process_response(response)
672
706
  end
673
707
 
@@ -688,8 +722,8 @@ module Capybara::Apparition
688
722
  def process_response(response)
689
723
  return nil if response.nil?
690
724
 
691
- exception_details = response['exceptionDetails']
692
- if (exception = exception_details&.dig('exception'))
725
+ exception = response['exceptionDetails']&.dig('exception')
726
+ if exception
693
727
  case exception['className']
694
728
  when 'DOMException'
695
729
  raise ::Capybara::Apparition::BrowserError.new('name' => exception['description'], 'args' => nil)
@@ -754,7 +788,11 @@ module Capybara::Apparition
754
788
  function(){
755
789
  let apparitionId=0;
756
790
  return (function ider(obj){
757
- if (obj && (typeof obj == 'object') && !(obj instanceof HTMLElement) && !obj.apparitionId){
791
+ if (obj &&
792
+ (typeof obj == 'object') &&
793
+ !(obj instanceof HTMLElement) &&
794
+ !(obj instanceof CSSStyleDeclaration) &&
795
+ !obj.apparitionId){
758
796
  obj.apparitionId = ++apparitionId;
759
797
  Reflect.ownKeys(obj).forEach(key => ider(obj[key]))
760
798
  }
@@ -780,12 +818,12 @@ module Capybara::Apparition
780
818
  JS
781
819
 
782
820
  CSS_FIND_JS = <<~JS
783
- Array.from(document.querySelectorAll("%s"));
821
+ Array.from(document.querySelectorAll("%<selector>s"));
784
822
  JS
785
823
 
786
824
  XPATH_FIND_JS = <<~JS
787
825
  (function(){
788
- const xpath = document.evaluate("%s", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
826
+ const xpath = document.evaluate("%<selector>s", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
789
827
  let results = [];
790
828
  for (let i=0; i < xpath.snapshotLength; i++){
791
829
  results.push(xpath.snapshotItem(i))