apparition 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Apparition
4
+ class Mouse
5
+ def initialize(page, keyboard)
6
+ @page = page
7
+ @keyboard = keyboard
8
+ end
9
+
10
+ def click_at(x:, y:, button: 'left', count: 1, modifiers: [])
11
+ move_to(x: x, y: y)
12
+ @keyboard.yield_with_keys(modifiers) do
13
+ down(x: x, y: y, button: button, count: count)
14
+ up(x: x, y: y, button: button, count: count)
15
+ end
16
+ end
17
+
18
+ def move_to(x:, y:, button: 'none')
19
+ @page.command('Input.dispatchMouseEvent',
20
+ type: 'mouseMoved',
21
+ button: button,
22
+ x: x,
23
+ y: y,
24
+ modifiers: @keyboard.modifiers)
25
+ end
26
+
27
+ def down(x:, y:, button: 'left', count: 1)
28
+ @page.command('Input.dispatchMouseEvent',
29
+ type: 'mousePressed',
30
+ button: button,
31
+ x: x,
32
+ y: y,
33
+ modifiers: @keyboard.modifiers,
34
+ clickCount: count)
35
+ end
36
+
37
+ def up(x:, y:, button: 'left', count: 1)
38
+ @page.command('Input.dispatchMouseEvent',
39
+ type: 'mouseReleased',
40
+ button: button,
41
+ x: x,
42
+ y: y,
43
+ modifiers: @keyboard.modifiers,
44
+ clickCount: count)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Apparition
4
+ module NetworkTraffic
5
+ require 'capybara/apparition/network_traffic/request'
6
+ require 'capybara/apparition/network_traffic/response'
7
+ require 'capybara/apparition/network_traffic/error'
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Apparition::NetworkTraffic
4
+ class Error
5
+ attr_reader :url, :code, :description
6
+ def initialize(url:, code:, description:)
7
+ @url = url
8
+ @code = code
9
+ @description = description
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Apparition::NetworkTraffic
4
+ class Request
5
+ attr_reader :response_parts, :response
6
+ attr_writer :blocked_params
7
+
8
+ def initialize(data, response_parts = [])
9
+ @data = data
10
+ @response_parts = response_parts
11
+ @response = nil
12
+ @blocked_params = nil
13
+ end
14
+
15
+ def response=(response)
16
+ @response_parts.push response
17
+ end
18
+
19
+ def request_id
20
+ @data['requestId']
21
+ end
22
+
23
+ def url
24
+ @data.dig('request', 'url')
25
+ end
26
+
27
+ def method
28
+ @data.dig('request', 'method')
29
+ end
30
+
31
+ def headers
32
+ @data.dig('requst', 'headers')
33
+ end
34
+
35
+ def time
36
+ @data['timestamp'] && Time.parse(@data['timestamp'])
37
+ end
38
+
39
+ def blocked?
40
+ !@blocked_params.nil?
41
+ end
42
+
43
+ def error
44
+ response_parts.last&.error
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara::Apparition::NetworkTraffic
4
+ class Response
5
+ def initialize(data)
6
+ @data = data
7
+ end
8
+
9
+ def url
10
+ @data['url']
11
+ end
12
+
13
+ def status
14
+ @data['status']
15
+ end
16
+
17
+ def status_text
18
+ @data['statusText']
19
+ end
20
+
21
+ def headers
22
+ @data['headers']
23
+ end
24
+
25
+ def redirect_url
26
+ @data['redirectURL']
27
+ end
28
+
29
+ def body_size
30
+ @data['bodySize']
31
+ end
32
+
33
+ def content_type
34
+ @data['contentType']
35
+ end
36
+
37
+ def from_cache?
38
+ @data['fromDiskCache'] == true
39
+ end
40
+
41
+ def time
42
+ @data['timestamp'] && Time.parse(@data['timestamp'])
43
+ end
44
+
45
+ def error
46
+ Error.new(url: url, code: status, description: status_text)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,844 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ module Capybara::Apparition
5
+ class Node < Capybara::Driver::Node
6
+ attr_reader :page_id
7
+
8
+ def initialize(driver, page, remote_object)
9
+ super(driver, self)
10
+ @page = page
11
+ @remote_object = remote_object
12
+ end
13
+
14
+ def id
15
+ @remote_object
16
+ end
17
+
18
+ def browser
19
+ driver.browser
20
+ end
21
+
22
+ def parents
23
+ find('xpath', 'ancestor::*').reverse
24
+ end
25
+
26
+ def find(method, selector)
27
+ results = if method == :css
28
+ evaluate_on <<~JS, value: selector
29
+ function(selector){
30
+ return Array.from(this.querySelectorAll(selector));
31
+ }
32
+ JS
33
+ else
34
+ evaluate_on <<~JS, value: selector
35
+ function(selector){
36
+ const xpath = document.evaluate(selector, this, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
37
+ let results = [];
38
+ for (let i=0; i < xpath.snapshotLength; i++){
39
+ results.push(xpath.snapshotItem(i));
40
+ }
41
+ return results;
42
+ }
43
+ JS
44
+ end
45
+
46
+ results.map { |r_o| Capybara::Apparition::Node.new(driver, @page, r_o['objectId']) }
47
+ rescue ::Capybara::Apparition::BrowserError => e
48
+ raise unless e.name =~ /is not a valid (XPath expression|selector)/
49
+
50
+ raise Capybara::Apparition::InvalidSelector, [method, selector]
51
+ end
52
+
53
+ def find_xpath(selector)
54
+ find :xpath, selector
55
+ end
56
+
57
+ def find_css(selector)
58
+ find :css, selector
59
+ end
60
+
61
+ def all_text
62
+ text = evaluate_on('function(){ return this.textContent }')
63
+ text.to_s.gsub(/[\u200b\u200e\u200f]/, '')
64
+ .gsub(/[\ \n\f\t\v\u2028\u2029]+/, ' ')
65
+ .gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
66
+ .gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
67
+ .tr("\u00a0", ' ')
68
+ end
69
+
70
+ def visible_text
71
+ return '' unless visible?
72
+
73
+ text = evaluate_on <<~JS
74
+ function(){
75
+ if (this.nodeName == 'TEXTAREA'){
76
+ return this.textContent;
77
+ } else if (this instanceof SVGElement) {
78
+ return this.textContent;
79
+ } else {
80
+ return this.innerText;
81
+ }
82
+ }
83
+ JS
84
+ text.to_s.gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
85
+ .gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
86
+ .gsub(/\n+/, "\n")
87
+ .tr("\u00a0", ' ')
88
+ end
89
+
90
+ def property(name)
91
+ evaluate_on('function(name){ return this[name] }', value: name)
92
+ end
93
+
94
+ def attribute(name)
95
+ if %w[checked selected].include?(name.to_s)
96
+ property(name)
97
+ else
98
+ evaluate_on('function(name){ return this.getAttribute(name)}', value: name)
99
+ end
100
+ end
101
+
102
+ def [](name)
103
+ # Although the attribute matters, the property is consistent. Return that in
104
+ # preference to the attribute for links and images.
105
+ if ((tag_name == 'img') && (name == 'src')) || ((tag_name == 'a') && (name == 'href'))
106
+ # if attribute exists get the property
107
+ return attribute(name) && property(name)
108
+ end
109
+
110
+ value = property(name)
111
+ value = attribute(name) if value.nil? || value.is_a?(Hash)
112
+
113
+ value
114
+ end
115
+
116
+ def attributes
117
+ evaluate_on <<~JS
118
+ function(){
119
+ let attrs = {};
120
+ for (let attr of this.attributes)
121
+ attrs[attr.name] = attr.value.replace("\\n","\\\\n");
122
+ return attrs;
123
+ }
124
+ JS
125
+ end
126
+
127
+ def value
128
+ evaluate_on <<~JS
129
+ function(){
130
+ if ((this.tagName == 'SELECT') && this.multiple){
131
+ let selected = [];
132
+ for (let option of this.children) {
133
+ if (option.selected) {
134
+ selected.push(option.value);
135
+ }
136
+ }
137
+ return selected;
138
+ } else {
139
+ return this.value;
140
+ }
141
+ }
142
+ JS
143
+ end
144
+
145
+ def set(value, **_options)
146
+ if tag_name == 'input'
147
+ case self[:type]
148
+ when 'radio'
149
+ click
150
+ when 'checkbox'
151
+ click if value != checked?
152
+ when 'file'
153
+ files = value.respond_to?(:to_ary) ? value.to_ary.map(&:to_s) : value.to_s
154
+ set_files(files)
155
+ when 'date'
156
+ set_date(value)
157
+ when 'time'
158
+ set_time(value)
159
+ when 'datetime-local'
160
+ set_datetime_local(value)
161
+ else
162
+ set_text(value.to_s)
163
+ end
164
+ elsif tag_name == 'textarea'
165
+ set_text(value.to_s)
166
+ elsif self[:isContentEditable]
167
+ delete_text
168
+ send_keys(value.to_s)
169
+ end
170
+ end
171
+
172
+ def select_option
173
+ return false if disabled?
174
+
175
+ evaluate_on <<~JS
176
+ function(){
177
+ let sel = this.parentNode;
178
+ if (sel.tagName == 'OPTGROUP'){
179
+ sel = sel.parentNode;
180
+ }
181
+ let event_options = { bubbles: true, cancelable: true };
182
+ sel.dispatchEvent(new FocusEvent('focus', event_options));
183
+
184
+ this.selected = true
185
+
186
+ sel.dispatchEvent(new Event('change', event_options));
187
+ sel.dispatchEvent(new FocusEvent('blur', event_options));
188
+
189
+ }
190
+ JS
191
+ true
192
+ end
193
+
194
+ def unselect_option
195
+ return false if disabled?
196
+
197
+ res = evaluate_on <<~JS
198
+ function(){
199
+ let sel = this.parentNode;
200
+ if (sel.tagName == 'OPTGROUP') {
201
+ sel = sel.parentNode;
202
+ }
203
+
204
+ if (!sel.multiple){
205
+ return false;
206
+ }
207
+
208
+ this.selected = false;
209
+ return true;
210
+ }
211
+ JS
212
+ res || raise(Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.')
213
+ end
214
+
215
+ def tag_name
216
+ @tag_name ||= evaluate_on('function(){ return this.tagName; }').downcase
217
+ end
218
+
219
+ def visible?
220
+ # if an area element, check visibility of relevant image
221
+ evaluate_on <<~JS
222
+ function(){
223
+ el = this;
224
+ if (el.tagName == 'AREA'){
225
+ const map_name = document.evaluate('./ancestor::map/@name', el, null, XPathResult.STRING_TYPE, null).stringValue;
226
+ el = document.querySelector(`img[usemap='#${map_name}']`);
227
+ if (!el){
228
+ return false;
229
+ }
230
+ }
231
+
232
+ while (el) {
233
+ const style = window.getComputedStyle(el);
234
+ if ((style.display == 'none') ||
235
+ (style.visibility == 'hidden') ||
236
+ (parseFloat(style.opacity) == 0)) {
237
+ return false;
238
+ }
239
+ el = el.parentElement;
240
+ }
241
+ return true;
242
+ }
243
+ JS
244
+ end
245
+
246
+ def checked?
247
+ self[:checked]
248
+ end
249
+
250
+ def selected?
251
+ !!self[:selected]
252
+ end
253
+
254
+ def disabled?
255
+ evaluate_on <<~JS
256
+ function() {
257
+ const xpath = 'parent::optgroup[@disabled] | \
258
+ ancestor::select[@disabled] | \
259
+ parent::fieldset[@disabled] | \
260
+ ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]';
261
+ return this.disabled || document.evaluate(xpath, this, null, XPathResult.BOOLEAN_TYPE, null).booleanValue
262
+ }
263
+ JS
264
+ end
265
+
266
+ def click(keys = [], button: 'left', count: 1, **options)
267
+ pos = if options[:x] && options[:y]
268
+ visible_top_left.tap do |p|
269
+ p[:x] += options[:x]
270
+ p[:y] += options[:y]
271
+ end
272
+ else
273
+ visible_center
274
+ end
275
+ raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['click']) if pos.nil?
276
+
277
+ test = mouse_event_test(pos)
278
+ raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['click', test.selector, pos]) unless test.success
279
+
280
+ @page.mouse.click_at pos.merge(button: button, count: count, modifiers: keys)
281
+ puts 'Waiting to see if click triggered page load' if ENV['DEBUG']
282
+ sleep 0.1
283
+ @page.wait_for_loaded(allow_obsolete: true)
284
+ end
285
+
286
+ def right_click(keys = [], **options)
287
+ click(keys, button: 'right', **options)
288
+ end
289
+
290
+ def double_click(keys = [], **options)
291
+ click(keys, count: 2, **options)
292
+ end
293
+
294
+ def hover
295
+ pos = visible_center
296
+ raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['hover']) if pos.nil?
297
+
298
+ @page.mouse.move_to(pos)
299
+ end
300
+
301
+ def drag_to(other, delay: 0.1)
302
+ pos = visible_center
303
+ raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if pos.nil?
304
+
305
+ test = mouse_event_test(pos)
306
+ raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test.selector, pos]) unless test.success
307
+
308
+ begin
309
+ @page.mouse.move_to(pos)
310
+ @page.mouse.down(pos)
311
+ sleep delay
312
+
313
+ other_pos = other.visible_center
314
+ raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['drag_to']) if other_pos.nil?
315
+
316
+ @page.mouse.move_to(other_pos.merge(button: 'left'))
317
+ sleep delay
318
+ ensure
319
+ @page.mouse.up(other_pos)
320
+ end
321
+ end
322
+
323
+ def drag_by(x, y, delay: 0.1)
324
+ pos = visible_center
325
+ raise ::Capybara::Apparition::MouseEventImpossible.new(self, 'args' => ['hover']) if pos.nil?
326
+
327
+ other_pos = { x: pos[:x] + x, y: pos[:y] + y }
328
+ raise ::Capybara::Apparition::MouseEventFailed.new(self, 'args' => ['drag', test['selector'], pos]) unless mouse_event_test?(pos)
329
+
330
+ @page.mouse.move_to(pos)
331
+ @page.mouse.down(pos)
332
+ sleep delay
333
+ @page.mouse.move_to(other_pos.merge(button: 'left'))
334
+ sleep delay
335
+ @page.mouse.up(other_pos)
336
+ end
337
+
338
+ EVENTS = {
339
+ blur: ['FocusEvent'],
340
+ focus: ['FocusEvent'],
341
+ focusin: ['FocusEvent', { bubbles: true }],
342
+ focusout: ['FocusEvent', { bubbles: true }],
343
+ click: ['MouseEvent', { bubbles: true, cancelable: true }],
344
+ dblckick: ['MouseEvent', { bubbles: true, cancelable: true }],
345
+ mousedown: ['MouseEvent', { bubbles: true, cancelable: true }],
346
+ mouseup: ['MouseEvent', { bubbles: true, cancelable: true }],
347
+ mouseenter: ['MouseEvent'],
348
+ mouseleave: ['MouseEvent'],
349
+ mousemove: ['MouseEvent', { bubbles: true, cancelable: true }],
350
+ submit: ['Event', { bubbles: true, cancelable: true }]
351
+ }.freeze
352
+
353
+ def trigger(name, **options)
354
+ raise ArgumentError, 'Unknown event' unless EVENTS.key?(name.to_sym)
355
+
356
+ event_type, opts = EVENTS[name.to_sym]
357
+ opts ||= {}
358
+
359
+ evaluate_on <<~JS, { value: name }, value: opts.merge(options)
360
+ function(name, options){
361
+ var event = new #{event_type}(name, options);
362
+ this.dispatchEvent(event);
363
+ }
364
+ JS
365
+ end
366
+
367
+ def ==(other)
368
+ evaluate_on('function(el){ return this == el; }', objectId: other.id)
369
+ rescue ObsoleteNode
370
+ false
371
+ end
372
+
373
+ def send_keys(*keys)
374
+ selected = evaluate_on <<~JS
375
+ function(){
376
+ let selectedNode = document.getSelection().focusNode;
377
+ if (!selectedNode)
378
+ return false;
379
+ if (selectedNode.nodeType == 3)
380
+ selectedNode = selectedNode.parentNode;
381
+ return this.contains(selectedNode);
382
+ }
383
+ JS
384
+ click unless selected
385
+ @page.keyboard.type(keys)
386
+ end
387
+ alias_method :send_key, :send_keys
388
+
389
+ def path
390
+ evaluate_on <<~JS
391
+ function(){
392
+ const xpath = document.evaluate('ancestor-or-self::node()', this, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
393
+ let elements = [];
394
+ for (let i=1; i<xpath.snapshotLength; i++){
395
+ elements.push(xpath.snapshotItem(i));
396
+ }
397
+ let selectors = elements.map( el => {
398
+ prev_siblings = document.evaluate(`./preceding-sibling::${el.tagName}`, el, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
399
+ return `${el.tagName}[${prev_siblings.snapshotLength + 1}]`;
400
+ })
401
+ return '//' + selectors.join('/');
402
+ }
403
+ JS
404
+ end
405
+
406
+ def visible_top_left
407
+ evaluate_on('function(){ this.scrollIntoViewIfNeeded() }')
408
+ # result = @page.command('DOM.getBoxModel', objectId: id)
409
+ result = evaluate_on <<~JS
410
+ function(){
411
+ var rect = this.getBoundingClientRect();
412
+ return rect.toJSON();
413
+ }
414
+ JS
415
+
416
+ return nil if result.nil?
417
+
418
+ result = result['model'] if result['model']
419
+ frame_offset = @page.current_frame_offset
420
+
421
+ if (result['width'].zero? || result['height'].zero?) && (tag_name == 'area')
422
+ map = find('xpath', 'ancestor::map').first
423
+ img = find('xpath', "//img[@usemap='##{map[:name]}']").first
424
+ return nil unless img.visible?
425
+
426
+ img_pos = img.top_left
427
+ coords = self[:coords].split(',').map(&:to_i)
428
+
429
+ offset_pos = case self[:shape]
430
+ when 'rect'
431
+ { x: coords[0], y: coords[1] }
432
+ when 'circle'
433
+ { x: coords[0], y: coords[1] }
434
+ when 'poly'
435
+ raise 'TODO: Poly not implemented'
436
+ else
437
+ raise 'Unknown Shape'
438
+ end
439
+
440
+ { x: img_pos[:x] + offset_pos[:x] + frame_offset[:x],
441
+ y: img_pos[:y] + offset_pos[:y] + frame_offset[:y] }
442
+ else
443
+ { x: result['left'] + frame_offset[:x],
444
+ y: result['top'] + frame_offset[:y] }
445
+ end
446
+ end
447
+
448
+ def visible_center
449
+ evaluate_on('function(){ this.scrollIntoViewIfNeeded() }')
450
+ # result = @page.command('DOM.getBoxModel', objectId: id)
451
+ result = evaluate_on <<~JS
452
+ function(){
453
+ var rect = this.getBoundingClientRect();
454
+ return rect.toJSON();
455
+ }
456
+ JS
457
+
458
+ return nil if result.nil?
459
+
460
+ result = result['model'] if result['model']
461
+ frame_offset = @page.current_frame_offset
462
+
463
+ if (result['width'].zero? || result['height'].zero?) && (tag_name == 'area')
464
+ map = find('xpath', 'ancestor::map').first
465
+ img = find('xpath', "//img[@usemap='##{map[:name]}']").first
466
+ return nil unless img.visible?
467
+
468
+ img_pos = img.top_left
469
+ coords = self[:coords].split(',').map(&:to_i)
470
+
471
+ offset_pos = case self[:shape]
472
+ when 'rect'
473
+ { x: (coords[0] + coords[2]) / 2,
474
+ y: (coords[1] + coords[2]) / 2 }
475
+ when 'circle'
476
+ { x: coords[0], y: coords[1] }
477
+ when 'poly'
478
+ raise 'TODO: Poly not implemented'
479
+ else
480
+ raise 'Unknown Shape'
481
+ end
482
+
483
+ { x: img_pos[:x] + offset_pos[:x] + frame_offset[:x],
484
+ y: img_pos[:y] + offset_pos[:y] + frame_offset[:y] }
485
+ else
486
+ lm = @page.command('Page.getLayoutMetrics')
487
+ # quad = result["border"]
488
+ # xs,ys = quad.partition.with_index { |_, idx| idx.even? }
489
+ xs = [result['left'], result['right']]
490
+ ys = [result['top'], result['bottom']]
491
+ x_extents, y_extents = xs.minmax, ys.minmax
492
+
493
+ x_extents[1] = [x_extents[1], lm['layoutViewport']['clientWidth']].min
494
+ y_extents[1] = [y_extents[1], lm['layoutViewport']['clientHeight']].min
495
+
496
+ { x: (x_extents.sum / 2) + frame_offset[:x],
497
+ y: (y_extents.sum / 2) + frame_offset[:y] }
498
+ end
499
+ end
500
+
501
+ def top_left
502
+ result = evaluate_on <<~JS
503
+ function(){
504
+ rect = this.getBoundingClientRect();
505
+ return rect.toJSON();
506
+ }
507
+ JS
508
+ # @page.command('DOM.getBoxModel', objectId: id)
509
+ return nil if result.nil?
510
+
511
+ # { x: result["model"]["content"][0],
512
+ # y: result["model"]["content"][1] }
513
+ { x: result['x'],
514
+ y: result['y'] }
515
+ end
516
+
517
+ def scroll_by(x, y)
518
+ driver.execute_script <<~JS, self, x, y
519
+ var el = arguments[0];
520
+ if (el.scrollBy){
521
+ el.scrollBy(arguments[1], arguments[2]);
522
+ } else {
523
+ el.scrollTop = el.scrollTop + arguments[2];
524
+ el.scrollLeft = el.scrollLeft + arguments[1];
525
+ }
526
+ JS
527
+ end
528
+
529
+ def scroll_to(element, location, position = nil)
530
+ # location, element = element, nil if element.is_a? Symbol
531
+ if element.is_a? Capybara::Apparition::Node
532
+ scroll_element_to_location(element, location)
533
+ elsif location.is_a? Symbol
534
+ scroll_to_location(location)
535
+ else
536
+ scroll_to_coords(*position)
537
+ end
538
+ self
539
+ end
540
+
541
+ private
542
+
543
+ def filter_text(text)
544
+ text.to_s.gsub(/[[:space:]]+/, ' ').strip
545
+ end
546
+
547
+ def evaluate_on(page_function, *args)
548
+ obsolete_checked_function = <<~JS
549
+ function(){
550
+ if (!this.ownerDocument.contains(this)) { throw 'ObsoleteNode' };
551
+ return #{page_function.strip}.apply(this, arguments);
552
+ }
553
+ JS
554
+ response = @page.command('Runtime.callFunctionOn',
555
+ functionDeclaration: obsolete_checked_function,
556
+ objectId: id,
557
+ returnByValue: false,
558
+ awaitPromise: true,
559
+ arguments: args)
560
+ process_response(response)
561
+ end
562
+
563
+ def process_response(response)
564
+ exception_details = response['exceptionDetails']
565
+ if exception_details && (exception = exception_details['exception'])
566
+ case exception['className']
567
+ when 'DOMException'
568
+ raise ::Capybara::Apparition::BrowserError.new('name' => exception['description'], 'args' => nil)
569
+ else
570
+ raise ::Capybara::Apparition::ObsoleteNode.new(self, '') if exception['value'] == 'ObsoleteNode'
571
+
572
+ puts "Unknown Exception: #{exception['value']}"
573
+ end
574
+ raise exception_details
575
+ end
576
+
577
+ result = response['result'] || response ['object']
578
+ if result['type'] == 'object'
579
+ if result['subtype'] == 'array'
580
+ remote_object = @page.command('Runtime.getProperties',
581
+ objectId: result['objectId'],
582
+ ownProperties: true)
583
+
584
+ return extract_properties_array(remote_object['result'])
585
+ elsif result['subtype'] == 'node'
586
+ return result
587
+ elsif result['className'] == 'Object'
588
+ remote_object = @page.command('Runtime.getProperties',
589
+ objectId: result['objectId'],
590
+ ownProperties: true)
591
+ extract_properties_object(remote_object['result'])
592
+ else
593
+ result['value']
594
+ end
595
+ else
596
+ result['value']
597
+ end
598
+ end
599
+
600
+ def set_text(value, clear: nil, **_unused)
601
+ value = value.to_s
602
+ if value.empty? && clear.nil?
603
+ evaluate_on <<~JS
604
+ function() {
605
+ this.focus();
606
+ this.value = '';
607
+ this.dispatchEvent(new Event('change', { bubbles: true }));
608
+ }
609
+ JS
610
+ elsif clear == :backspace
611
+ # Clear field by sending the correct number of backspace keys.
612
+ backspaces = [:backspace] * self.value.to_s.length
613
+ send_keys(*([:end] + backspaces + [value]))
614
+ elsif clear.is_a? Array
615
+ send_keys(*clear, value)
616
+ else
617
+ # Clear field by JavaScript assignment of the value property.
618
+ # Script can change a readonly element which user input cannot, so
619
+ # don't execute if readonly.
620
+ driver.execute_script "arguments[0].value = ''", self unless clear == :none
621
+ send_keys(value)
622
+ end
623
+ end
624
+
625
+ def set_files(files)
626
+ @page.command('DOM.setFileInputFiles',
627
+ files: Array(files),
628
+ objectId: id)
629
+ end
630
+
631
+ def set_date(value)
632
+ value = SettableValue.new(value)
633
+ return set_text(value) unless value.dateable?
634
+
635
+ # TODO: this would be better if locale can be detected and correct keystrokes sent
636
+ update_value_js(value.to_date_str)
637
+ end
638
+
639
+ def set_time(value)
640
+ value = SettableValue.new(value)
641
+ return set_text(value) unless value.timeable?
642
+
643
+ # TODO: this would be better if locale can be detected and correct keystrokes sent
644
+ update_value_js(value.to_time_str)
645
+ end
646
+
647
+ def set_datetime_local(value)
648
+ value = SettableValue.new(value)
649
+ return set_text(value) unless value.timeable?
650
+
651
+ # TODO: this would be better if locale can be detected and correct keystrokes sent
652
+ update_value_js(value.to_datetime_str)
653
+ end
654
+
655
+ def update_value_js(value)
656
+ evaluate_on(<<~JS, value: value)
657
+ function(value){
658
+ if (document.activeElement !== this){
659
+ this.focus();
660
+ }
661
+ if (this.value != value) {
662
+ this.value = value;
663
+ this.dispatchEvent(new InputEvent('input'));
664
+ this.dispatchEvent(new Event('change', { bubbles: true }));
665
+ }
666
+ }
667
+ JS
668
+ end
669
+
670
+ def mouse_event_test?(x:, y:)
671
+ mouse_event_test(x: x, y: y).success
672
+ end
673
+
674
+ def mouse_event_test(x:, y:)
675
+ frame_offset = @page.current_frame_offset
676
+ # return { status: 'failure' } if x < 0 || y < 0
677
+ result = evaluate_on(<<~JS, { value: x - frame_offset[:x] }, value: y - frame_offset[:y])
678
+ function(x,y){
679
+ const hit_node = document.elementFromPoint(x,y);
680
+ if ((hit_node == this) || this.contains(hit_node))
681
+ return { status: 'success' };
682
+
683
+ const getSelector = function(element){
684
+ if (element == null)
685
+ return 'Element out of bounds';
686
+
687
+ let selector = '';
688
+ if (element.tagName != 'HTML')
689
+ selector = getSelector(element.parentNode) + ' ';
690
+ selector += element.tagName.toLowerCase();
691
+ if (element.id)
692
+ selector += `#${element.id}`;
693
+
694
+ for (let className of element.classList){
695
+ if (className != '')
696
+ selector += `.${className}`;
697
+ }
698
+ return selector;
699
+ }
700
+
701
+ return { status: 'failure', selector: getSelector(hit_node) };
702
+ }
703
+ JS
704
+
705
+ OpenStruct.new(success: result['status'] == 'success', selector: result['selector'])
706
+ end
707
+
708
+ def scroll_element_to_location(element, location)
709
+ scroll_opts = case location
710
+ when :top
711
+ 'true'
712
+ when :bottom
713
+ 'false'
714
+ when :center
715
+ "{behavior: 'instant', block: 'center'}"
716
+ else
717
+ raise ArgumentError, "Invalid scroll_to location: #{location}"
718
+ end
719
+ driver.execute_script <<~JS, element
720
+ arguments[0].scrollIntoView(#{scroll_opts})
721
+ JS
722
+ end
723
+
724
+ def scroll_to_location(location)
725
+ scroll_y = case location
726
+ when :top
727
+ '0'
728
+ when :bottom
729
+ 'arguments[0].scrollHeight'
730
+ when :center
731
+ '(arguments[0].scrollHeight - arguments[0].clientHeight)/2'
732
+ end
733
+
734
+ driver.execute_script <<~JS, self
735
+ arguments[0].scrollTo(0, #{scroll_y});
736
+ JS
737
+ end
738
+
739
+ def scroll_to_coords(x, y)
740
+ driver.execute_script <<~JS, self, x, y
741
+ arguments[0].scrollTo(arguments[1], arguments[2]);
742
+ JS
743
+ end
744
+
745
+ # evaluate_on("function(hit_node){
746
+ # if ((this == hit_node) || (this.contains(hit_node)))
747
+ # return { status: 'success' };
748
+ #
749
+ # const getSelector = function(element){
750
+ # let selector = '';
751
+ # if (element.tagName != 'HTML')
752
+ # selector = getSelector(element.parentNode) + ' ';
753
+ # selector += element.tagName.toLowerCase();
754
+ # if (element.id)
755
+ # selector += `#${element.id}`;
756
+ #
757
+ # for (let className of element.classList){
758
+ # if (className != '')
759
+ # selector += `.${className}`;
760
+ # }
761
+ # return selector;
762
+ # }
763
+ #
764
+ # return { status: 'failure', selector: getSelector(hit_node)};
765
+ # }", objectId: hit_node_id)
766
+
767
+ def delete_text
768
+ evaluate_on <<~JS
769
+ function(){
770
+ range = document.createRange();
771
+ range.selectNodeContents(this);
772
+ window.getSelection().removeAllRanges();
773
+ window.getSelection().addRange(range);
774
+ window.getSelection().deleteFromDocument();
775
+ window.getSelection().removeAllRanges();
776
+ }
777
+ JS
778
+ end
779
+
780
+ # SettableValue encapsulates time/date field formatting
781
+ class SettableValue
782
+ attr_reader :value
783
+
784
+ def initialize(value)
785
+ @value = value
786
+ end
787
+
788
+ def to_s
789
+ value.to_s
790
+ end
791
+
792
+ def dateable?
793
+ !value.is_a?(String) && value.respond_to?(:to_date)
794
+ end
795
+
796
+ def to_date_str
797
+ value.to_date.strftime('%Y-%m-%d')
798
+ end
799
+
800
+ def timeable?
801
+ !value.is_a?(String) && value.respond_to?(:to_time)
802
+ end
803
+
804
+ def to_time_str
805
+ value.to_time.strftime('%H:%M')
806
+ end
807
+
808
+ def to_datetime_str
809
+ value.to_time.strftime('%Y-%m-%dT%H:%M')
810
+ end
811
+ end
812
+ private_constant :SettableValue
813
+
814
+ def extract_properties_array(properties)
815
+ properties.each_with_object([]) do |property, results|
816
+ if property['enumerable']
817
+ if property.dig('value', 'subtype') == 'node'
818
+ results.push(property['value'])
819
+ else
820
+ # releasePromises.push(helper.releaseObject(@element._client, property.value))
821
+ results.push(property.dig('value', 'value'))
822
+ end
823
+ end
824
+ # await Promise.all(releasePromises);
825
+ # id = (@page._elements.push(element)-1 for element from result)[0]
826
+ #
827
+ # new Apparition.Node @page, id
828
+
829
+ # releasePromises = [helper.releaseObject(@element._client, remote_object)]
830
+ end
831
+ end
832
+
833
+ def extract_properties_object(properties)
834
+ properties.each_with_object({}) do |property, object|
835
+ if property['enumerable']
836
+ object[property['name']] = property['value']['value']
837
+ else
838
+ # releasePromises.push(helper.releaseObject(@element._client, property.value))
839
+ end
840
+ # releasePromises = [helper.releaseObject(@element._client, remote_object)]
841
+ end
842
+ end
843
+ end
844
+ end