apparition 0.0.1

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.
@@ -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