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,711 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/apparition/frame_manager'
4
+ require 'capybara/apparition/mouse'
5
+ require 'capybara/apparition/keyboard'
6
+
7
+ module Capybara::Apparition
8
+ class Page
9
+ attr_reader :modal_messages
10
+ attr_reader :mouse, :keyboard
11
+ attr_reader :viewport_size
12
+ attr_accessor :perm_headers, :temp_headers, :temp_no_redirect_headers
13
+ attr_reader :network_traffic
14
+
15
+ def self.create(browser, session, id, ignore_https_errors: nil, screenshot_task_queue: nil, js_errors: false)
16
+ session.command 'Page.enable'
17
+ session.command 'Page.setLifecycleEventsEnabled', enabled: true
18
+ session.command 'Page.setDownloadBehavior', behavior: 'allow', downloadPath: Capybara.save_path
19
+
20
+ page = Page.new(browser, session, id, ignore_https_errors, screenshot_task_queue, js_errors)
21
+
22
+ session.command 'Network.enable'
23
+ session.command 'Runtime.enable'
24
+ session.command 'Security.enable'
25
+ session.command 'Security.setOverrideCertificateErrors', override: true if ignore_https_errors
26
+ session.command 'DOM.enable'
27
+ # session.command 'Log.enable'
28
+
29
+ page
30
+ end
31
+
32
+ def initialize(browser, session, id, _ignore_https_errors, _screenshot_task_queue, js_errors)
33
+ @target_id = id
34
+ @browser = browser
35
+ @session = session
36
+ @keyboard = Keyboard.new(self)
37
+ @mouse = Mouse.new(self, @keyboard)
38
+ @modals = []
39
+ @modal_messages = []
40
+ @frames = Capybara::Apparition::FrameManager.new(id)
41
+ @response_headers = {}
42
+ @status_code = nil
43
+ @url_blacklist = []
44
+ @url_whitelist = []
45
+ @auth_attempts = []
46
+ @perm_headers = {}
47
+ @temp_headers = {}
48
+ @temp_no_redirect_headers = {}
49
+ @viewport_size = nil
50
+ @network_traffic = []
51
+ @open_resource_requests = {}
52
+ @js_error = nil
53
+
54
+ register_event_handlers
55
+
56
+ register_js_error_handler if js_errors
57
+ end
58
+
59
+ def usable?
60
+ !!current_frame&.context_id
61
+ end
62
+
63
+ def reset
64
+ @modals.clear
65
+ @modal_messages.clear
66
+ @response_headers = {}
67
+ @status_code = nil
68
+ @auth_attempts = []
69
+ @perm_headers = {}
70
+ end
71
+
72
+ def add_modal(modal_response)
73
+ @last_modal_message = nil
74
+ @modals.push(modal_response)
75
+ end
76
+
77
+ def credentials=(creds)
78
+ @credentials = creds
79
+ setup_network_interception
80
+ end
81
+
82
+ def url_blacklist=(blacklist)
83
+ @url_blacklist = blacklist
84
+ setup_network_blocking
85
+ end
86
+
87
+ def url_whitelist=(whitelist)
88
+ @url_whitelist = whitelist
89
+ setup_network_blocking
90
+ end
91
+
92
+ def clear_network_traffic
93
+ @network_traffic = []
94
+ end
95
+
96
+ def scroll_to(left, top)
97
+ wait_for_loaded
98
+ execute('window.scrollTo(arguments[0], arguments[1])', left, top)
99
+ end
100
+
101
+ def click_at(x, y)
102
+ wait_for_loaded
103
+ @mouse.click_at(x: x, y: y)
104
+ end
105
+
106
+ def current_state
107
+ main_frame.state
108
+ end
109
+
110
+ def current_frame_offset
111
+ return { x: 0, y: 0 } if current_frame.id == main_frame.id
112
+
113
+ result = command('DOM.getBoxModel', objectId: current_frame.element_id)
114
+ x, y = result['model']['content']
115
+ { x: x, y: y }
116
+ end
117
+
118
+ def render(options)
119
+ wait_for_loaded
120
+ if options[:format].to_s == 'pdf'
121
+ params = {}
122
+ params[:paperWidth] = @browser.paper_size[:width].to_f if @browser.paper_size
123
+ params[:paperHeight] = @browser.paper_size[:height].to_f if @browser.paper_size
124
+ command('Page.printToPDF', params)
125
+ else
126
+ if options[:selector]
127
+ pos = evaluate("document.querySelector('#{options.delete(:selector)}').getBoundingClientRect().toJSON();")
128
+ options[:clip] = %w[x y width height].each_with_object('scale' => 1) { |key, hash| hash[key] = pos[key] }
129
+ elsif options[:full]
130
+ options[:clip] = evaluate <<~JS
131
+ { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight}
132
+ JS
133
+ options[:clip].merge!(x: 0, y: 0, scale: 1)
134
+ end
135
+ command('Page.captureScreenshot', options)
136
+ end['data']
137
+ end
138
+
139
+ def push_frame(frame_el)
140
+ node = command('DOM.describeNode', objectId: frame_el.base.id)
141
+ frame_id = node['node']['frameId']
142
+ start = Time.now
143
+ while (frame = @frames.get(frame_id)).nil? || frame.loading?
144
+ # Wait for the frame creation messages to be processed
145
+ if Time.now - start > 10
146
+ puts 'Timed out waiting from frame to be ready'
147
+ # byebug
148
+ raise TimeoutError.new('push_frame')
149
+ end
150
+ sleep 0.1
151
+ end
152
+ return unless frame
153
+
154
+ frame.element_id = frame_el.base.id
155
+ @frames.push_frame(frame.id)
156
+ frame
157
+ end
158
+
159
+ def pop_frame(top: false)
160
+ @frames.pop_frame(top: top)
161
+ end
162
+
163
+ def find(method, selector)
164
+ wait_for_loaded
165
+ js_escaped_selector = selector.gsub('\\', '\\\\\\').gsub('"', '\"')
166
+ query = method == :css ? CSS_FIND_JS : XPATH_FIND_JS
167
+ result = _raw_evaluate(query % js_escaped_selector)
168
+ (result || []).map { |r_o| [self, r_o['objectId']] }
169
+ rescue ::Capybara::Apparition::BrowserError => e
170
+ raise unless e.name =~ /is not a valid (XPath expression|selector)/
171
+
172
+ raise Capybara::Apparition::InvalidSelector, [method, selector]
173
+ end
174
+
175
+ def execute(script, *args)
176
+ wait_for_loaded
177
+ _execute_script("function(){ #{script} }", *args)
178
+ nil
179
+ end
180
+
181
+ def evaluate(script, *args)
182
+ wait_for_loaded
183
+ _execute_script("function(){ return #{script} }", *args)
184
+ end
185
+
186
+ def evaluate_async(script, _wait_time, *args)
187
+ wait_for_loaded
188
+ _execute_script("function(){
189
+ var args = Array.prototype.slice.call(arguments);
190
+ return new Promise((resolve, reject)=>{
191
+ args.push(resolve);
192
+ var fn = function(){ #{script} };
193
+ fn.apply(this, args);
194
+ });
195
+ }", *args)
196
+ end
197
+
198
+ def refresh
199
+ wait_for_loaded
200
+ main_frame.reloading!
201
+ command('Page.reload', ignoreCache: true)
202
+ wait_for_loaded
203
+ end
204
+
205
+ def go_back
206
+ wait_for_loaded
207
+ go_history(-1)
208
+ end
209
+
210
+ def go_forward
211
+ wait_for_loaded
212
+ go_history(+1)
213
+ end
214
+
215
+ attr_reader :response_headers
216
+
217
+ attr_reader :status_code
218
+
219
+ def wait_for_loaded(allow_obsolete: false)
220
+ start = Time.now
221
+ cf = current_frame
222
+ until cf.usable? || (allow_obsolete && cf.obsolete?) || @js_error
223
+ if Time.now - start > 10
224
+ puts 'Timedout waiting for page to be loaded'
225
+ # byebug
226
+ raise TimeoutError.new('wait_for_loaded')
227
+ end
228
+ sleep 0.05
229
+ end
230
+ raise JavascriptError.new(js_error) if @js_error
231
+ end
232
+
233
+ def content
234
+ wait_for_loaded
235
+ _raw_evaluate("(function(){
236
+ let val = '';
237
+ if (document.doctype)
238
+ val = new XMLSerializer().serializeToString(document.doctype);
239
+ if (document.documentElement)
240
+ val += document.documentElement.outerHTML;
241
+ return val;
242
+ })()")
243
+ end
244
+
245
+ def visit(url)
246
+ wait_for_loaded
247
+ @status_code = nil
248
+ navigate_opts = { url: url, transitionType: 'reload' }
249
+ navigate_opts[:referrer] = extra_headers['Referer'] if extra_headers['Referer']
250
+ response = command('Page.navigate', navigate_opts)
251
+ main_frame.loading(response['loaderId'])
252
+ wait_for_loaded
253
+ rescue TimeoutError
254
+ raise StatusFailError.new('args' => [url])
255
+ end
256
+
257
+ def current_url
258
+ wait_for_loaded
259
+ _raw_evaluate('window.location.href', context_id: main_frame.context_id)
260
+ end
261
+
262
+ def frame_url
263
+ wait_for_loaded
264
+ _raw_evaluate('window.location.href')
265
+ end
266
+
267
+ def set_viewport(width:, height:, screen: nil)
268
+ wait_for_loaded
269
+ @viewport_size = { width: width, height: height }
270
+ result = @browser.command('Browser.getWindowForTarget', targetId: @target_id)
271
+ @browser.command('Browser.setWindowBounds',
272
+ windowId: result['windowId'],
273
+ bounds: { width: width, height: height })
274
+ metrics = {
275
+ mobile: false,
276
+ width: width,
277
+ height: height,
278
+ deviceScaleFactor: 1
279
+ }
280
+ metrics[:screenWidth], metrics[:screenHeight] = *screen if screen
281
+
282
+ command('Emulation.setDeviceMetricsOverride', metrics)
283
+ end
284
+
285
+ def fullscreen
286
+ result = @browser.command('Browser.getWindowForTarget', targetId: @target_id)
287
+ @browser.command('Browser.setWindowBounds', windowId: result['windowId'], bounds: { windowState: 'fullscreen' })
288
+ end
289
+
290
+ def maximize
291
+ screen_width, screen_height = *evaluate('[window.screen.width, window.screen.height]')
292
+ set_viewport(width: screen_width, height: screen_height)
293
+
294
+ result = @browser.command('Browser.getWindowForTarget', targetId: @target_id)
295
+ @browser.command('Browser.setWindowBounds', windowId: result['windowId'], bounds: { windowState: 'maximized' })
296
+ end
297
+
298
+ def title
299
+ wait_for_loaded
300
+ _raw_evaluate('document.title', context_id: main_frame.context_id)
301
+ end
302
+
303
+ def frame_title
304
+ wait_for_loaded
305
+ _raw_evaluate('document.title')
306
+ end
307
+
308
+ def command(name, **params)
309
+ @browser.command_for_session(@session.session_id, name, params)
310
+ end
311
+
312
+ def async_command(name, **params)
313
+ @browser.command_for_session(@session.session_id, name, params, async: true)
314
+ end
315
+
316
+ def extra_headers
317
+ temp_headers.merge(perm_headers).merge(temp_no_redirect_headers)
318
+ end
319
+
320
+ def update_headers(async: false)
321
+ method = async ? :async_command : :command
322
+ if extra_headers['User-Agent']
323
+ send(method, 'Network.setUserAgentOverride', userAgent: extra_headers['User-Agent'])
324
+ end
325
+ send(method, 'Network.setExtraHTTPHeaders', headers: extra_headers)
326
+ setup_network_interception
327
+ end
328
+
329
+ def inherit(page)
330
+ if page
331
+ self.url_whitelist = page.url_whitelist.dup
332
+ self.url_blacklist = page.url_blacklist.dup
333
+ set_viewport(page.viewport_size) if page.viewport_size
334
+ end
335
+ self
336
+ end
337
+
338
+ def js_error
339
+ res = @js_error
340
+ @js_error = nil
341
+ res
342
+ end
343
+
344
+ protected
345
+
346
+ attr_reader :url_blacklist, :url_whitelist
347
+
348
+ private
349
+
350
+ def register_event_handlers
351
+ @session.on 'Page.javascriptDialogOpening' do |params|
352
+ type = params['type'].to_sym
353
+ accept = if type == :beforeunload
354
+ true
355
+ else
356
+ response = @modals.pop
357
+ if !response&.key?(type)
358
+ handle_unexpected_modal(type)
359
+ else
360
+ @modal_messages.push(params['message'])
361
+ response[type]
362
+ end
363
+ end
364
+
365
+ if type == :prompt
366
+ case accept
367
+ when false
368
+ async_command('Page.handleJavaScriptDialog', accept: false)
369
+ when nil
370
+ async_command('Page.handleJavaScriptDialog', accept: true, promptText: params['defaultPrompt'])
371
+ else
372
+ async_command('Page.handleJavaScriptDialog', accept: true, promptText: accept)
373
+ end
374
+ else
375
+ async_command('Page.handleJavaScriptDialog', accept: accept)
376
+ end
377
+ end
378
+
379
+ @session.on 'Page.windowOpen' do |params|
380
+ puts "**** windowOpen was called with: #{params}" if ENV['DEBUG']
381
+ end
382
+
383
+ @session.on 'Page.frameAttached' do |params|
384
+ puts "**** frameAttached called with #{params}" if ENV['DEBUG']
385
+ # @frames.get(params["frameId"]) = Frame.new(params)
386
+ end
387
+
388
+ @session.on 'Page.frameDetached' do |params|
389
+ @frames.delete(params['frameId'])
390
+ puts "**** frameDetached called with #{params}" if ENV['DEBUG']
391
+ end
392
+
393
+ @session.on 'Page.frameNavigated' do |params|
394
+ puts "**** frameNavigated called with #{params}" if ENV['DEBUG']
395
+ frame_params = params['frame']
396
+ unless @frames.exists?(frame_params['id'])
397
+ puts "**** creating frome for #{frame_params['id']}" if ENV['DEBUG']
398
+ @frames.add(frame_params['id'], frame_params)
399
+ end
400
+ end
401
+
402
+ @session.on 'Page.lifecycleEvent' do |params|
403
+ puts "Lifecycle: #{params['name']} - frame: #{params['frameId']} - loader: #{params['loaderId']}" if ENV['DEBUG']
404
+ case params['name']
405
+ when 'init'
406
+ @frames.get(params['frameId'])&.loading(params['loaderId'])
407
+ when 'firstMeaningfulPaintCandidate',
408
+ 'networkIdle'
409
+ frame = @frames.get(params['frameId'])
410
+ frame.loaded! if frame.loader_id == params['loaderId']
411
+ end
412
+ end
413
+
414
+ @session.on 'Page.navigatedWithinDocument' do |params|
415
+ puts "**** navigatedWithinDocument called with #{params}" if ENV['DEBUG']
416
+ frame_id = params['frameId']
417
+ # @frames.get(frame_id).state = :loaded if frame_id == main_frame.id
418
+ @frames.get(frame_id).loaded! if frame_id == main_frame.id
419
+ end
420
+
421
+ @session.on 'Runtime.executionContextCreated' do |params|
422
+ puts "**** executionContextCreated: #{params}" if ENV['DEBUG']
423
+ context = params['context']
424
+ frame_id = context.dig('auxData', 'frameId')
425
+ if context.dig('auxData', 'isDefault') && frame_id
426
+ if (frame = @frames.get(frame_id))
427
+ frame.context_id = context['id']
428
+ elsif ENV['DEBUG']
429
+ puts "unknown frame for context #{frame_id}"
430
+ end
431
+ end
432
+ # command 'Network.setRequestInterception', patterns: [{urlPattern: '*'}]
433
+ end
434
+
435
+ @session.on 'Runtime.executionContextDestroyed' do |params|
436
+ puts "executionContextDestroyed: #{params}" if ENV['DEBUG']
437
+ @frames.destroy_context(params['executionContextId'])
438
+ end
439
+
440
+ @session.on 'Network.requestWillBeSent' do |params|
441
+ @open_resource_requests[params['requestId']] = params.dig('request', 'url')
442
+ end
443
+
444
+ @session.on 'Network.responseReceived' do |params|
445
+ @open_resource_requests.delete(params['requestId'])
446
+ temp_headers.clear
447
+ update_headers(async: true)
448
+ end
449
+
450
+ @session.on 'Network.requestWillBeSent' do |params|
451
+ @network_traffic.push(NetworkTraffic::Request.new(params))
452
+ end
453
+
454
+ @session.on 'Network.responseReceived' do |params|
455
+ req = @network_traffic.find { |request| request.request_id == params['requestId'] }
456
+ req.response = NetworkTraffic::Response.new(params['response']) if req
457
+ end
458
+
459
+ @session.on 'Network.responseReceived' do |params|
460
+ if params['type'] == 'Document'
461
+ @response_headers = params['response']['headers']
462
+ @status_code = params['response']['status']
463
+ end
464
+ end
465
+
466
+ @session.on 'Network.loadingFailed' do |params|
467
+ req = @network_traffic.find { |request| request.request_id == params['requestId'] }
468
+ req&.blocked_params = params if params['blockedReason']
469
+ puts "Loading Failed - request: #{params['requestId']}: #{params['errorText']}" if params['type'] == 'Document'
470
+ end
471
+
472
+ @session.on 'Network.requestIntercepted' do |params|
473
+ request, interception_id = *params.values_at('request', 'interceptionId')
474
+ if params['authChallenge']
475
+ handle_auth(interception_id)
476
+ else
477
+ process_intercepted_request(interception_id, request, params['isNavigationRequest'])
478
+ end
479
+ end
480
+
481
+ @session.on 'Runtime.consoleAPICalled' do |params|
482
+ @browser.logger&.puts(
483
+ "#{params['type']}: #{params['args'].map { |arg| arg['description'] || arg['value'] }.join(' ')}"
484
+ )
485
+ end
486
+
487
+ # @session.on 'Log.entryAdded' do |params|
488
+ # log_entry = params['entry']
489
+ # if params.values_at('source', 'level') == ['javascript', 'error']
490
+ # @js_error ||= params['string']
491
+ # end
492
+ # end
493
+ end
494
+
495
+ def register_js_error_handler
496
+ @session.on 'Runtime.exceptionThrown' do |params|
497
+ @js_error ||= params.dig('exceptionDetails', 'exception', 'description')
498
+ end
499
+ end
500
+
501
+ def setup_network_blocking
502
+ command 'Network.setBlockedURLs', urls: @url_blacklist
503
+ setup_network_interception
504
+ end
505
+
506
+ def setup_network_interception
507
+ async_command 'Network.setCacheDisabled', cacheDisabled: true
508
+ async_command 'Network.setRequestInterception', patterns: [{ urlPattern: '*' }]
509
+ end
510
+
511
+ def process_intercepted_request(interception_id, request, navigation)
512
+ headers, url = request.values_at('headers', 'url')
513
+
514
+ unless @temp_headers.empty? || navigation
515
+ headers.delete_if { |name, value| @temp_headers[name] == value }
516
+ end
517
+ unless @temp_no_redirect_headers.empty? || !navigation
518
+ headers.delete_if { |name, value| @temp_no_redirect_headers[name] == value }
519
+ end
520
+
521
+ if @url_blacklist.any? { |r| url.match Regexp.escape(r).gsub('\*', '.*?') }
522
+ block_request(interception_id, 'Failed')
523
+ elsif @url_whitelist.any?
524
+ if @url_whitelist.any? { |r| url.match Regexp.escape(r).gsub('\*', '.*?') }
525
+ continue_request(interception_id, headers: headers)
526
+ else
527
+ block_request(interception_id, 'Failed')
528
+ end
529
+ else
530
+ continue_request(interception_id, headers: headers)
531
+ end
532
+ end
533
+
534
+ def continue_request(id, **params)
535
+ async_command 'Network.continueInterceptedRequest', interceptionId: id, **params
536
+ end
537
+
538
+ def block_request(id, reason)
539
+ async_command 'Network.continueInterceptedRequest', errorReason: reason, interceptionId: id
540
+ end
541
+
542
+ def current_frame
543
+ @frames.current
544
+ end
545
+
546
+ def main_frame
547
+ @frames.main
548
+ end
549
+
550
+ def go_history(delta)
551
+ history = command('Page.getNavigationHistory')
552
+ entry = history['entries'][history['currentIndex'] + delta]
553
+ return nil unless entry
554
+
555
+ command('Page.navigateToHistoryEntry', entryId: entry['id'])
556
+ end
557
+
558
+ def _execute_script(script, *args)
559
+ args = args.map do |arg|
560
+ if arg.is_a? Capybara::Apparition::Node
561
+ { objectId: arg.id }
562
+ else
563
+ { value: arg }
564
+ end
565
+ end
566
+ context_id = current_frame&.context_id
567
+ response = command('Runtime.callFunctionOn',
568
+ functionDeclaration: script,
569
+ executionContextId: context_id,
570
+ arguments: args,
571
+ returnByValue: false,
572
+ awaitPromise: true)
573
+ process_response(response)
574
+ end
575
+
576
+ def _raw_evaluate(page_function, context_id: nil)
577
+ wait_for_loaded
578
+ return unless page_function.is_a? String
579
+
580
+ context_id ||= current_frame.context_id
581
+
582
+ response = command('Runtime.evaluate',
583
+ expression: page_function,
584
+ contextId: context_id,
585
+ returnByValue: false,
586
+ awaitPromise: true)
587
+ process_response(response)
588
+ end
589
+
590
+ def process_response(response)
591
+ return nil if response.nil?
592
+
593
+ exception_details = response['exceptionDetails']
594
+ if (exception = exception_details&.dig('exception'))
595
+ case exception['className']
596
+ when 'DOMException'
597
+ raise ::Capybara::Apparition::BrowserError.new('name' => exception['description'], 'args' => nil)
598
+ when 'ObsoleteException'
599
+ raise ::Capybara::Apparition::ObsoleteNode.new(self, '') if exception['value'] == 'ObsoleteNode'
600
+ else
601
+ raise Capybara::Apparition::JavascriptError, [exception['description']]
602
+ end
603
+ end
604
+
605
+ result = response['result']
606
+ decode_result(result)
607
+ end
608
+
609
+ def handle_unexpected_modal(type)
610
+ case type
611
+ when :prompt
612
+ warn 'Unexpected prompt modal - accepting with the default value.' \
613
+ 'This is deprecated behavior, start using `accept_prompt`.'
614
+ nil
615
+ when :confirm
616
+ warn 'Unexpected confirm modal - accepting.' \
617
+ 'This is deprecated behavior, start using `accept_confirm`.'
618
+ true
619
+ else
620
+ raise "Unexpected #{type} modal"
621
+ end
622
+ end
623
+
624
+ def handle_auth(interception_id)
625
+ credentials_response = if @auth_attempts.include?(interception_id)
626
+ { response: 'CancelAuth' }
627
+ else
628
+ @auth_attempts.push(interception_id)
629
+ { response: 'ProvideCredentials' }.merge(@credentials || {})
630
+ end
631
+ continue_request(interception_id, authChallengeResponse: credentials_response)
632
+ end
633
+
634
+ def decode_result(result, object_cache = {})
635
+ if result['type'] == 'object'
636
+ if result['subtype'] == 'array'
637
+ # remoteObject = @browser.command('Runtime.getProperties',
638
+ remote_object = command('Runtime.getProperties',
639
+ objectId: result['objectId'],
640
+ ownProperties: true)
641
+
642
+ properties = remote_object['result']
643
+ results = []
644
+
645
+ properties.each do |property|
646
+ next unless property['enumerable']
647
+
648
+ val = property['value']
649
+ results.push(decode_result(val, object_cache))
650
+ # TODO: Do we need to cleanup these resources?
651
+ # await Promise.all(releasePromises);
652
+ # id = (@page._elements.push(element)-1 for element from result)[0]
653
+ #
654
+ # new Apparition.Node @page, id
655
+
656
+ # releasePromises = [helper.releaseObject(@element._client, remoteObject)]
657
+ end
658
+
659
+ return results
660
+ elsif result['subtype'] == 'node'
661
+ return result
662
+ elsif result['className'] == 'Object'
663
+ remote_object = command('Runtime.getProperties',
664
+ objectId: result['objectId'],
665
+ ownProperties: true)
666
+ stable_id = remote_object['internalProperties']
667
+ .find { |prop| prop['name'] == '[[StableObjectId]]' }
668
+ .dig('value', 'value')
669
+ # We could actually return cyclic objects here but Capybara would need to be updated to support
670
+ return '(cyclic structure)' if object_cache.key?(stable_id)
671
+
672
+ # return object_cache[stable_id] if object_cache.key?(stable_id)
673
+
674
+ object_cache[stable_id] = {}
675
+ properties = remote_object['result']
676
+
677
+ return properties.each_with_object(object_cache[stable_id]) do |property, memo|
678
+ if property['enumerable']
679
+ memo[property['name']] = decode_result(property['value'], object_cache)
680
+ else
681
+ # TODO: Do we need to cleanup these resources?
682
+ # releasePromises.push(helper.releaseObject(@element._client, property.value))
683
+ end
684
+ # TODO: Do we need to cleanup these resources?
685
+ # releasePromises = [helper.releaseObject(@element._client, remote_object)]
686
+ end
687
+ elsif result['className'] == 'Window'
688
+ return { object_id: result['objectId'] }
689
+ end
690
+ nil
691
+ else
692
+ result['value']
693
+ end
694
+ end
695
+
696
+ CSS_FIND_JS = <<~JS
697
+ Array.from(document.querySelectorAll("%s"));
698
+ JS
699
+
700
+ XPATH_FIND_JS = <<~JS
701
+ (function(){
702
+ const xpath = document.evaluate("%s", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
703
+ let results = [];
704
+ for (let i=0; i < xpath.snapshotLength; i++){
705
+ results.push(xpath.snapshotItem(i))
706
+ };
707
+ return results;
708
+ })()
709
+ JS
710
+ end
711
+ end