apparition 0.1.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -4
  3. data/lib/capybara/apparition.rb +0 -2
  4. data/lib/capybara/apparition/browser.rb +75 -133
  5. data/lib/capybara/apparition/browser/cookie.rb +4 -16
  6. data/lib/capybara/apparition/browser/header.rb +2 -2
  7. data/lib/capybara/apparition/browser/launcher.rb +25 -0
  8. data/lib/capybara/apparition/browser/launcher/local.rb +213 -0
  9. data/lib/capybara/apparition/browser/launcher/remote.rb +55 -0
  10. data/lib/capybara/apparition/browser/page_manager.rb +90 -0
  11. data/lib/capybara/apparition/browser/window.rb +29 -29
  12. data/lib/capybara/apparition/configuration.rb +100 -0
  13. data/lib/capybara/apparition/console.rb +8 -1
  14. data/lib/capybara/apparition/dev_tools_protocol/remote_object.rb +23 -7
  15. data/lib/capybara/apparition/dev_tools_protocol/session.rb +3 -4
  16. data/lib/capybara/apparition/driver.rb +107 -35
  17. data/lib/capybara/apparition/driver/chrome_client.rb +13 -8
  18. data/lib/capybara/apparition/driver/response.rb +1 -1
  19. data/lib/capybara/apparition/driver/web_socket_client.rb +1 -0
  20. data/lib/capybara/apparition/errors.rb +3 -3
  21. data/lib/capybara/apparition/network_traffic/error.rb +1 -0
  22. data/lib/capybara/apparition/network_traffic/request.rb +5 -5
  23. data/lib/capybara/apparition/node.rb +142 -50
  24. data/lib/capybara/apparition/node/drag.rb +165 -65
  25. data/lib/capybara/apparition/page.rb +180 -142
  26. data/lib/capybara/apparition/page/frame.rb +3 -0
  27. data/lib/capybara/apparition/page/frame_manager.rb +2 -1
  28. data/lib/capybara/apparition/page/keyboard.rb +29 -7
  29. data/lib/capybara/apparition/page/mouse.rb +20 -6
  30. data/lib/capybara/apparition/utility.rb +1 -1
  31. data/lib/capybara/apparition/version.rb +1 -1
  32. metadata +53 -23
  33. data/lib/capybara/apparition/dev_tools_protocol/target.rb +0 -64
  34. data/lib/capybara/apparition/dev_tools_protocol/target_manager.rb +0 -48
  35. data/lib/capybara/apparition/driver/launcher.rb +0 -217
@@ -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))