apparition 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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