puppeteer-ruby 0.28.1 → 0.29.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61aae6f779d8c0b4566cb9fd8966ba7d5941bda2b0c43754d61616dab29ca555
4
- data.tar.gz: f5394e8ac944d188795e8a4eff87a60b2b226b659d8183ac1003d2d9f1c9c27b
3
+ metadata.gz: 2b852a48cd11f269c3b4eec0a85583b120a5cd1b0bc204e27a6ec0e365aa33dd
4
+ data.tar.gz: 8864c21fe02f6e3fcbb07c6c009516e91273ecfe0636437bc41015187e9985ad
5
5
  SHA512:
6
- metadata.gz: e51fc1ffce61bf542d4057803ae3f2ac811285b54a221d4bc2ce6cfccc18e9de2828ba98de82d246efa7b1fdf58c44cce8e9205ac1343889f173462b8dd8f546
7
- data.tar.gz: 4b5706d9f926372415acafa3c0d9339d653dd35542642a48593d400fd929ec03f1f32cd5624d99b0c1e870471f591c385b18b02b742d8b83d501fef599f64ffc
6
+ metadata.gz: dd3cdb6d7b64b0b6f6e2568eb83cad62f36ea45943f0df9cd434c6e30221ccd47b161a0368cbe02cacf4a6a3c6c6ecf293bd1313829d7f02daed5d1d2c5c9f73
7
+ data.tar.gz: 541c2dffa4c6359dff6c2d9d03e70a039057d554c1ee6509d0574d194f17a6407a968c30153bae850f8c4c0e9fa7ade7beaea2dcc8d136a896e80b22831a436f
data/.circleci/config.yml CHANGED
@@ -26,7 +26,7 @@ jobs:
26
26
 
27
27
  rspec_chrome_ruby3_0:
28
28
  docker:
29
- - image: circleci/ruby:3.0.0-rc1-buster-node-browsers
29
+ - image: circleci/ruby:3.0.0-buster-node-browsers
30
30
  <<: *rspec_chrome_job
31
31
 
32
32
  rspec_firefox:
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: Feature request
3
+ about: Request a new feature for playwright-ruby-client
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ ### Simple description about the feature
11
+
12
+
13
+ ### Usecase / Motivation
14
+
15
+ <!-- Describe why the feature helps. -->
@@ -12,4 +12,4 @@ jobs:
12
12
  with:
13
13
  github_token: ${{ secrets.github_token }}
14
14
  reporter: github-pr-review
15
- rubocop_version: 1.8.0
15
+ rubocop_version: 1.10.0
data/CHANGELOG.md CHANGED
@@ -1,7 +1,14 @@
1
- ### master [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.28.1...master)]
1
+ ### master [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.29.0...master)]
2
2
 
3
3
  * xxx
4
4
 
5
+ ### 0.29.0 [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.28.1...0.29.0)]
6
+
7
+ New features:
8
+
9
+ * Add `AriaQueryHandler`. Now we can use "aria/...." for selectors.
10
+
11
+
5
12
  ### 0.28.1 [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.0.27...0.28.1)]
6
13
 
7
14
  New features:
data/README.md CHANGED
@@ -1,15 +1,31 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/puppeteer-ruby.svg)](https://badge.fury.io/rb/puppeteer-ruby)
2
2
 
3
- # Puppeteer in Ruby [UNDER HEAVY DEVELOPMENT]
3
+ # Puppeteer in Ruby
4
4
 
5
5
  A Ruby port of [puppeteer](https://pptr.dev/).
6
6
 
7
7
  ![logo](puppeteer-ruby.png)
8
8
 
9
- REMARK: This Gem is NOT production-ready!!
9
+ REMARK: This Gem covers just a part of Puppeteer APIs. Feedbacks and feature requests are welcome :)
10
10
 
11
11
  ## Getting Started
12
12
 
13
+ ### Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'puppeteer-ruby'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install puppeteer-ruby
28
+
13
29
  ### Capture a site
14
30
 
15
31
  ```ruby
data/lib/puppeteer.rb CHANGED
@@ -19,12 +19,14 @@ require 'puppeteer/event_callbackable'
19
19
  require 'puppeteer/if_present'
20
20
 
21
21
  # Classes & values.
22
+ require 'puppeteer/aria_query_handler'
22
23
  require 'puppeteer/browser'
23
24
  require 'puppeteer/browser_context'
24
25
  require 'puppeteer/browser_runner'
25
26
  require 'puppeteer/cdp_session'
26
27
  require 'puppeteer/connection'
27
28
  require 'puppeteer/console_message'
29
+ require 'puppeteer/custom_query_handler'
28
30
  require 'puppeteer/devices'
29
31
  require 'puppeteer/dialog'
30
32
  require 'puppeteer/dom_world'
@@ -41,6 +43,7 @@ require 'puppeteer/lifecycle_watcher'
41
43
  require 'puppeteer/mouse'
42
44
  require 'puppeteer/network_manager'
43
45
  require 'puppeteer/page'
46
+ require 'puppeteer/query_handler_manager'
44
47
  require 'puppeteer/remote_object'
45
48
  require 'puppeteer/request'
46
49
  require 'puppeteer/response'
@@ -0,0 +1,71 @@
1
+ class Puppeteer::AriaQueryHandler
2
+ private def normalize(value)
3
+ value.gsub(/ +/, ' ').strip
4
+ end
5
+
6
+ # @param selector [String]
7
+ private def parse_aria_selector(selector)
8
+ known_attributes = %w(name role)
9
+ query_options = {}
10
+ attribute_regexp = /\[\s*(?<attribute>\w+)\s*=\s*"(?<value>\\.|[^"\\]*)"\s*\]/
11
+ default_name = selector.gsub(attribute_regexp) do
12
+ attribute = $1.strip
13
+ value = $2
14
+ unless known_attributes.include?(attribute)
15
+ raise ArgumentError.new("Unkown aria attribute \"#{attribute}\" in selector")
16
+ end
17
+ query_options[attribute.to_sym] = normalize(value)
18
+ ''
19
+ end
20
+
21
+ if default_name.length > 0
22
+ query_options[:name] ||= normalize(default_name)
23
+ end
24
+
25
+ query_options
26
+ end
27
+
28
+ def query_one(element, selector)
29
+ context = element.execution_context
30
+ parse_result = parse_aria_selector(selector)
31
+ res = element.query_ax_tree(accessible_name: parse_result[:name], role: parse_result[:role])
32
+ if res.empty?
33
+ nil
34
+ else
35
+ context.adopt_backend_node_id(res.first['backendDOMNodeId'])
36
+ end
37
+ end
38
+
39
+ def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil)
40
+ binding_function = Puppeteer::DOMWorld::BindingFunction.new(
41
+ name: 'ariaQuerySelector',
42
+ proc: -> (selector) { query_one(dom_world.send(:document), selector) },
43
+ )
44
+ dom_world.send(:wait_for_selector_in_page,
45
+ '(_, selector) => globalThis.ariaQuerySelector(selector)',
46
+ selector,
47
+ visible: visible,
48
+ hidden: hidden,
49
+ timeout: timeout,
50
+ binding_function: binding_function)
51
+ end
52
+
53
+ def query_all(element, selector)
54
+ context = element.execution_context
55
+ parse_result = parse_aria_selector(selector)
56
+ res = element.query_ax_tree(accessible_name: parse_result[:name], role: parse_result[:role])
57
+ if res.empty?
58
+ nil
59
+ else
60
+ promises = res.map do |ax_node|
61
+ context.send(:async_adopt_backend_node_id, ax_node['backendDOMNodeId'])
62
+ end
63
+ await_all(*promises)
64
+ end
65
+ end
66
+
67
+ def query_all_array(element, selector)
68
+ element_handles = query_all(element, selector)
69
+ element.execution_context.evaluate_handle('(...elements) => elements', *element_handles)
70
+ end
71
+ end
@@ -0,0 +1,51 @@
1
+ class Puppeteer::CustomQueryHandler
2
+ # @param query_one [String] JS function (element: Element | Document, selector: string) => Element | null;
3
+ # @param query_all [String] JS function (element: Element | Document, selector: string) => Element[] | NodeListOf<Element>;
4
+ def initialize(query_one: nil, query_all: nil)
5
+ @query_one = query_one
6
+ @query_all = query_all
7
+ end
8
+
9
+ def query_one(element, selector)
10
+ unless @query_one
11
+ raise NotImplementedError.new("#{self.class}##{__method__} is not implemented.")
12
+ end
13
+
14
+ handle = element.evaluate_handle(@query_one, selector)
15
+ element = handle.as_element
16
+
17
+ if element
18
+ return element
19
+ end
20
+ handle.dispose
21
+ nil
22
+ end
23
+
24
+ def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil)
25
+ unless @query_one
26
+ raise NotImplementedError.new("#{self.class}##{__method__} is not implemented.")
27
+ end
28
+
29
+ dom_world.send(:wait_for_selector_in_page, @query_one, selector, visible: visible, hidden: hidden, timeout: timeout)
30
+ end
31
+
32
+ def query_all(element, selector)
33
+ unless @query_all
34
+ raise NotImplementedError.new("#{self.class}##{__method__} is not implemented.")
35
+ end
36
+
37
+ handles = element.evaluate_handle(@query_all, selector)
38
+ properties = handles.properties
39
+ handles.dispose
40
+ properties.values.map(&:as_element).compact
41
+ end
42
+
43
+ def query_all_array(element, selector)
44
+ unless @query_all
45
+ raise NotImplementedError.new("#{self.class}##{__method__} is not implemented.")
46
+ end
47
+
48
+ handles = element.evaluate_handle(@query_all, selector)
49
+ handles.evaluate_handle('(res) => Array.from(res)')
50
+ end
51
+ end
@@ -4,6 +4,47 @@ require 'thread'
4
4
  class Puppeteer::DOMWorld
5
5
  using Puppeteer::DefineAsyncMethod
6
6
 
7
+ class BindingFunction
8
+ def initialize(name:, proc:)
9
+ @name = name
10
+ @proc = proc
11
+ end
12
+
13
+ def call(*args)
14
+ @proc.call(*args)
15
+ end
16
+
17
+ attr_reader :name
18
+
19
+ def page_binding_init_string
20
+ <<~JAVASCRIPT
21
+ (type, bindingName) => {
22
+ /* Cast window to any here as we're about to add properties to it
23
+ * via win[bindingName] which TypeScript doesn't like.
24
+ */
25
+ const win = window;
26
+ const binding = win[bindingName];
27
+
28
+ win[bindingName] = (...args) => {
29
+ const me = window[bindingName];
30
+ let callbacks = me.callbacks;
31
+ if (!callbacks) {
32
+ callbacks = new Map();
33
+ me.callbacks = callbacks;
34
+ }
35
+ const seq = (me.lastSeq || 0) + 1;
36
+ me.lastSeq = seq;
37
+ const promise = new Promise((resolve, reject) =>
38
+ callbacks.set(seq, { resolve, reject })
39
+ );
40
+ binding(JSON.stringify({ type, name: bindingName, seq, args }));
41
+ return promise;
42
+ };
43
+ }
44
+ JAVASCRIPT
45
+ end
46
+ end
47
+
7
48
  # @param {!Puppeteer.FrameManager} frameManager
8
49
  # @param {!Puppeteer.Frame} frame
9
50
  # @param {!Puppeteer.TimeoutSettings} timeoutSettings
@@ -13,19 +54,29 @@ class Puppeteer::DOMWorld
13
54
  @timeout_settings = timeout_settings
14
55
  @context_promise = resolvable_future
15
56
  @wait_tasks = Set.new
57
+ @bound_functions = {}
58
+ @ctx_bindings = Set.new
16
59
  @detached = false
60
+
61
+ frame_manager.client.on_event('Runtime.bindingCalled', &method(:handle_binding_called))
17
62
  end
18
63
 
19
64
  attr_reader :frame
20
65
 
21
66
  # only used in Puppeteer::WaitTask#initialize
22
- def _wait_tasks
67
+ private def _wait_tasks
23
68
  @wait_tasks
24
69
  end
25
70
 
71
+ # only used in Puppeteer::WaitTask#initialize
72
+ private def _bound_functions
73
+ @bound_functions
74
+ end
75
+
26
76
  # @param context [Puppeteer::ExecutionContext]
27
77
  def context=(context)
28
78
  if context
79
+ @ctx_bindings.clear
29
80
  unless @context_promise.resolved?
30
81
  @context_promise.fulfill(context)
31
82
  end
@@ -378,61 +429,127 @@ class Puppeteer::DOMWorld
378
429
  # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
379
430
  # @param timeout [Integer]
380
431
  def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil)
381
- wait_for_selector_or_xpath(selector, false, visible: visible, hidden: hidden, timeout: timeout)
432
+ # call wait_for_selector_in_page with custom query selector.
433
+ query_selector_manager = Puppeteer::QueryHandlerManager.instance
434
+ query_selector_manager.detect_query_handler(selector).wait_for(self, visible: visible, hidden: hidden, timeout: timeout)
382
435
  end
383
436
 
384
- # @param xpath [String]
385
- # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
386
- # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
387
- # @param timeout [Integer]
388
- def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil)
389
- wait_for_selector_or_xpath(xpath, true, visible: visible, hidden: hidden, timeout: timeout)
437
+ private def binding_identifier(name, context)
438
+ "#{name}_#{context.send(:_context_id)}"
390
439
  end
391
440
 
392
- # /**
393
- # * @param {Function|string} pageFunction
394
- # * @param {!{polling?: string|number, timeout?: number}=} options
395
- # * @return {!Promise<!Puppeteer.JSHandle>}
396
- # */
397
- # waitForFunction(pageFunction, options = {}, ...args) {
398
- # const {
399
- # polling = 'raf',
400
- # timeout = this._timeoutSettings.timeout(),
401
- # } = options;
402
- # return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise;
403
- # }
404
441
 
405
- # @param page_function [String]
406
- # @param args [Array]
407
- # @param polling [Integer|String]
442
+ def add_binding_to_context(context, binding_function)
443
+ return if @ctx_bindings.include?(binding_identifier(binding_function.name, context))
444
+
445
+ expression = binding_function.page_binding_init_string
446
+ begin
447
+ context.client.send_message('Runtime.addBinding',
448
+ name: binding_function.name,
449
+ executionContextName: context.send(:_context_name))
450
+ context.evaluate(expression, 'internal', binding_function.name)
451
+ rescue => err
452
+ # We could have tried to evaluate in a context which was already
453
+ # destroyed. This happens, for example, if the page is navigated while
454
+ # we are trying to add the binding
455
+ allowed = [
456
+ 'Execution context was destroyed',
457
+ 'Cannot find context with specified id',
458
+ ]
459
+ if allowed.any? { |msg| err.message.include?(msg) }
460
+ # ignore
461
+ else
462
+ raise
463
+ end
464
+ end
465
+ @ctx_bindings << binding_identifier(binding_function.name, context)
466
+ end
467
+
468
+ private def handle_binding_called(event)
469
+ return unless has_context?
470
+ payload = JSON.parse(event['payload']) rescue nil
471
+ name = payload['name']
472
+ args = payload['args']
473
+
474
+ # The binding was either called by something in the page or it was
475
+ # called before our wrapper was initialized.
476
+ return unless payload
477
+ return unless payload['type'] == 'internal'
478
+ context = execution_context
479
+ return unless @ctx_bindings.include?(binding_identifier(name, context))
480
+ return unless context.send(:_context_id) == event['executionContextId']
481
+
482
+ result = @bound_functions[name].call(*args)
483
+ deliver_result_js = <<~JAVASCRIPT
484
+ (name, seq, result) => {
485
+ globalThis[name].callbacks.get(seq).resolve(result);
486
+ globalThis[name].callbacks.delete(seq);
487
+ }
488
+ JAVASCRIPT
489
+
490
+ begin
491
+ context.evaluate(deliver_result_js, name, payload['seq'], result)
492
+ rescue => err
493
+ # The WaitTask may already have been resolved by timing out, or the
494
+ # exection context may have been destroyed.
495
+ # In both caes, the promises above are rejected with a protocol error.
496
+ # We can safely ignores these, as the WaitTask is re-installed in
497
+ # the next execution context if needed.
498
+ return if err.message.include?('Protocol error')
499
+ raise
500
+ end
501
+ end
502
+
503
+ # @param query_one [String] JS function (element: Element | Document, selector: string) => Element | null;
504
+ # @param selector [String]
505
+ # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
506
+ # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
408
507
  # @param timeout [Integer]
409
- # @return [Puppeteer::JSHandle]
410
- def wait_for_function(page_function, args: [], polling: nil, timeout: nil)
411
- option_polling = polling || 'raf'
508
+ private def wait_for_selector_in_page(query_one, selector, visible: nil, hidden: nil, timeout: nil, binding_function: nil)
509
+ option_wait_for_visible = visible || false
510
+ option_wait_for_hidden = hidden || false
412
511
  option_timeout = timeout || @timeout_settings.timeout
413
512
 
414
- Puppeteer::WaitTask.new(
513
+ polling =
514
+ if option_wait_for_visible || option_wait_for_hidden
515
+ 'raf'
516
+ else
517
+ 'mutation'
518
+ end
519
+ title = "selector #{selector}#{option_wait_for_hidden ? 'to be hidden' : ''}"
520
+
521
+ selector_predicate = make_predicate_string(
522
+ predicate_arg_def: '(selector, waitForVisible, waitForHidden)',
523
+ predicate_query_handler: query_one,
524
+ async: true,
525
+ predicate_body: <<~JAVASCRIPT
526
+ const node = await predicateQueryHandler(document, selector)
527
+ return checkWaitForOptions(node, waitForVisible, waitForHidden);
528
+ JAVASCRIPT
529
+ )
530
+
531
+ wait_task = Puppeteer::WaitTask.new(
415
532
  dom_world: self,
416
- predicate_body: page_function,
417
- title: 'function',
418
- polling: option_polling,
533
+ predicate_body: selector_predicate,
534
+ title: title,
535
+ polling: polling,
419
536
  timeout: option_timeout,
420
- args: args,
421
- ).await_promise
422
- end
423
-
424
-
425
- # @return [String]
426
- def title
427
- evaluate('() => document.title')
537
+ args: [selector, option_wait_for_visible, option_wait_for_hidden],
538
+ binding_function: binding_function,
539
+ )
540
+ handle = wait_task.await_promise
541
+ unless handle.as_element
542
+ handle.dispose
543
+ return nil
544
+ end
545
+ handle.as_element
428
546
  end
429
547
 
430
- # @param selector_or_xpath [String]
431
- # @param is_xpath [Boolean]
548
+ # @param xpath [String]
432
549
  # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
433
550
  # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
434
551
  # @param timeout [Integer]
435
- private def wait_for_selector_or_xpath(selector_or_xpath, is_xpath, visible: nil, hidden: nil, timeout: nil)
552
+ def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil)
436
553
  option_wait_for_visible = visible || false
437
554
  option_wait_for_hidden = hidden || false
438
555
  option_timeout = timeout || @timeout_settings.timeout
@@ -443,15 +560,23 @@ class Puppeteer::DOMWorld
443
560
  else
444
561
  'mutation'
445
562
  end
446
- title = "#{is_xpath ? :XPath : :selector} #{selector_or_xpath}#{option_wait_for_hidden ? 'to be hidden' : ''}"
563
+ title = "XPath #{xpath}#{option_wait_for_hidden ? 'to be hidden' : ''}"
564
+
565
+ xpath_predicate = make_predicate_string(
566
+ predicate_arg_def: '(selector, waitForVisible, waitForHidden)',
567
+ predicate_body: <<~JAVASCRIPT
568
+ const node = document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
569
+ return checkWaitForOptions(node, waitForVisible, waitForHidden);
570
+ JAVASCRIPT
571
+ )
447
572
 
448
573
  wait_task = Puppeteer::WaitTask.new(
449
574
  dom_world: self,
450
- predicate_body: PREDICATE,
575
+ predicate_body: xpath_predicate,
451
576
  title: title,
452
577
  polling: polling,
453
578
  timeout: option_timeout,
454
- args: [selector_or_xpath, is_xpath, option_wait_for_visible, option_wait_for_hidden],
579
+ args: [xpath, option_wait_for_visible, option_wait_for_hidden],
455
580
  )
456
581
  handle = wait_task.await_promise
457
582
  unless handle.as_element
@@ -461,34 +586,66 @@ class Puppeteer::DOMWorld
461
586
  handle.as_element
462
587
  end
463
588
 
464
- PREDICATE = <<~JAVASCRIPT
465
- /**
466
- * @param {string} selectorOrXPath
467
- * @param {boolean} isXPath
468
- * @param {boolean} waitForVisible
469
- * @param {boolean} waitForHidden
470
- * @return {?Node|boolean}
471
- */
472
- function _(selectorOrXPath, isXPath, waitForVisible, waitForHidden) {
473
- const node = isXPath
474
- ? document.evaluate(selectorOrXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
475
- : document.querySelector(selectorOrXPath);
476
- if (!node)
477
- return waitForHidden;
478
- if (!waitForVisible && !waitForHidden)
479
- return node;
480
- const element = /** @type {Element} */ (node.nodeType === Node.TEXT_NODE ? node.parentElement : node);
481
- const style = window.getComputedStyle(element);
482
- const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
483
- const success = (waitForVisible === isVisible || waitForHidden === !isVisible);
484
- return success ? node : null;
485
- /**
486
- * @return {boolean}
487
- */
488
- function hasVisibleBoundingBox() {
489
- const rect = element.getBoundingClientRect();
490
- return !!(rect.top || rect.bottom || rect.width || rect.height);
491
- }
492
- }
493
- JAVASCRIPT
589
+ # @param page_function [String]
590
+ # @param args [Array]
591
+ # @param polling [Integer|String]
592
+ # @param timeout [Integer]
593
+ # @return [Puppeteer::JSHandle]
594
+ def wait_for_function(page_function, args: [], polling: nil, timeout: nil)
595
+ option_polling = polling || 'raf'
596
+ option_timeout = timeout || @timeout_settings.timeout
597
+
598
+ Puppeteer::WaitTask.new(
599
+ dom_world: self,
600
+ predicate_body: page_function,
601
+ title: 'function',
602
+ polling: option_polling,
603
+ timeout: option_timeout,
604
+ args: args,
605
+ ).await_promise
606
+ end
607
+
608
+
609
+ # @return [String]
610
+ def title
611
+ evaluate('() => document.title')
612
+ end
613
+
614
+ private def make_predicate_string(predicate_arg_def:, predicate_body:, predicate_query_handler: nil, async: false)
615
+ predicate_query_handler_string =
616
+ if predicate_query_handler
617
+ "const predicateQueryHandler = #{predicate_query_handler}"
618
+ else
619
+ ""
620
+ end
621
+
622
+ <<~JAVASCRIPT
623
+ #{async ? 'async ' : ''}function _#{predicate_arg_def} {
624
+ #{predicate_query_handler_string}
625
+ #{predicate_body}
626
+
627
+ function checkWaitForOptions(node, waitForVisible, waitForHidden) {
628
+ if (!node) return waitForHidden;
629
+ if (!waitForVisible && !waitForHidden) return node;
630
+ const element =
631
+ node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
632
+
633
+ const style = window.getComputedStyle(element);
634
+ const isVisible =
635
+ style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
636
+ const success =
637
+ waitForVisible === isVisible || waitForHidden === !isVisible;
638
+ return success ? node : null;
639
+
640
+ /**
641
+ * @return {boolean}
642
+ */
643
+ function hasVisibleBoundingBox() {
644
+ const rect = element.getBoundingClientRect();
645
+ return !!(rect.top || rect.bottom || rect.width || rect.height);
646
+ }
647
+ }
648
+ }
649
+ JAVASCRIPT
650
+ end
494
651
  end
@@ -314,32 +314,20 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
314
314
  end
315
315
  end
316
316
 
317
+ private def query_handler_manager
318
+ Puppeteer::QueryHandlerManager.instance
319
+ end
320
+
317
321
  # `$()` in JavaScript. $ is not allowed to use as a method name in Ruby.
318
322
  # @param selector [String]
319
323
  def S(selector)
320
- handle = evaluate_handle(
321
- '(element, selector) => element.querySelector(selector)',
322
- selector,
323
- )
324
- element = handle.as_element
325
-
326
- if element
327
- return element
328
- end
329
- handle.dispose
330
- nil
324
+ query_handler_manager.detect_query_handler(selector).query_one(self)
331
325
  end
332
326
 
333
327
  # `$$()` in JavaScript. $ is not allowed to use as a method name in Ruby.
334
328
  # @param selector [String]
335
329
  def SS(selector)
336
- handles = evaluate_handle(
337
- '(element, selector) => element.querySelectorAll(selector)',
338
- selector,
339
- )
340
- properties = handles.properties
341
- handles.dispose
342
- properties.values.map(&:as_element).compact
330
+ query_handler_manager.detect_query_handler(selector).query_all(self)
343
331
  end
344
332
 
345
333
  class ElementNotFoundError < StandardError
@@ -370,10 +358,7 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
370
358
  # @param page_function [String]
371
359
  # @return [Object]
372
360
  def SSeval(selector, page_function, *args)
373
- handles = evaluate_handle(
374
- '(element, selector) => Array.from(element.querySelectorAll(selector))',
375
- selector,
376
- )
361
+ handles = query_handler_manager.detect_query_handler(selector).query_all_array(self)
377
362
  result = handles.evaluate(page_function, *args)
378
363
  handles.dispose
379
364
 
@@ -430,4 +415,10 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
430
415
  # https://en.wikipedia.org/wiki/Polygon#Simple_polygons
431
416
  quad.zip(quad.rotate).map { |p1, p2| (p1.x * p2.y - p2.x * p1.y) / 2 }.reduce(:+).abs
432
417
  end
418
+
419
+ # used in AriaQueryHandler
420
+ def query_ax_tree(accessible_name: nil, role: nil)
421
+ @remote_object.query_ax_tree(@client,
422
+ accessible_name: accessible_name, role: role)
423
+ end
433
424
  end
@@ -12,10 +12,21 @@ class Puppeteer::ExecutionContext
12
12
  @client = client
13
13
  @world = world
14
14
  @context_id = context_payload['id']
15
+ @context_name = context_payload['name']
15
16
  end
16
17
 
17
18
  attr_reader :client, :world
18
19
 
20
+ # only used in DOMWorld
21
+ private def _context_id
22
+ @context_id
23
+ end
24
+
25
+ # only used in DOMWorld::BindingFunction#add_binding_to_context
26
+ private def _context_name
27
+ @context_name
28
+ end
29
+
19
30
  # @return [Puppeteer::Frame]
20
31
  def frame
21
32
  if_present(@world) do |world|
@@ -223,6 +234,7 @@ class Puppeteer::ExecutionContext
223
234
  remote_object: Puppeteer::RemoteObject.new(response["object"]),
224
235
  )
225
236
  end
237
+ private define_async_method :async_adopt_backend_node_id
226
238
 
227
239
  # @param element_handle [Puppeteer::ElementHandle]
228
240
  # @return [Puppeteer::ElementHandle]
@@ -0,0 +1,65 @@
1
+ require 'singleton'
2
+
3
+ class Puppeteer::QueryHandlerManager
4
+ include Singleton
5
+
6
+ def query_handlers
7
+ @query_handlers ||= {
8
+ aria: Puppeteer::AriaQueryHandler.new,
9
+ }
10
+ end
11
+
12
+ private def default_handler
13
+ @default_handler ||= Puppeteer::CustomQueryHandler.new(
14
+ query_one: '(element, selector) => element.querySelector(selector)',
15
+ query_all: '(element, selector) => element.querySelectorAll(selector)',
16
+ )
17
+ end
18
+
19
+ class Result
20
+ def initialize(query_handler:, selector:)
21
+ @query_handler = query_handler
22
+ @selector = selector
23
+ end
24
+
25
+ def query_one(element_handle)
26
+ @query_handler.query_one(element_handle, @selector)
27
+ end
28
+
29
+ def wait_for(dom_world, visible:, hidden:, timeout:)
30
+ @query_handler.wait_for(dom_world, @selector, visible: visible, hidden: hidden, timeout: timeout)
31
+ end
32
+
33
+ def query_all(element_handle)
34
+ @query_handler.query_all(element_handle, @selector)
35
+ end
36
+
37
+ def query_all_array(element_handle)
38
+ @query_handler.query_all_array(element_handle, @selector)
39
+ end
40
+ end
41
+
42
+ def detect_query_handler(selector)
43
+ unless /^[a-zA-Z]+\// =~ selector
44
+ return Result.new(
45
+ query_handler: default_handler,
46
+ selector: selector,
47
+ )
48
+ end
49
+
50
+ chunk = selector.split("/")
51
+ name = chunk.shift
52
+ updated_selector = chunk.join("/")
53
+
54
+ query_handler = query_handlers[name.to_sym]
55
+
56
+ unless query_handler
57
+ raise ArgumentError.new("Query set to use \"#{name}\", but no query handler of that name was found")
58
+ end
59
+
60
+ Result.new(
61
+ query_handler: query_handler,
62
+ selector: updated_selector,
63
+ )
64
+ end
65
+ end
@@ -97,6 +97,18 @@ class Puppeteer::RemoteObject
97
97
  nil
98
98
  end
99
99
 
100
+ # used in ElementHandle#query_ax_tree
101
+ def query_ax_tree(client, accessible_name: nil, role: nil)
102
+ result = client.send_message('Accessibility.queryAXTree', {
103
+ objectId: @object_id,
104
+ accessibleName: accessible_name,
105
+ role: role,
106
+ }.compact)
107
+
108
+ result['nodes'].reject do |node|
109
+ node['role']['value'] == 'text'
110
+ end
111
+ end
100
112
 
101
113
  # helper#valueFromRemoteObject
102
114
  def value
@@ -1,3 +1,3 @@
1
1
  class Puppeteer
2
- VERSION = '0.28.1'
2
+ VERSION = '0.29.0'
3
3
  end
@@ -9,7 +9,7 @@ class Puppeteer::WaitTask
9
9
  end
10
10
  end
11
11
 
12
- def initialize(dom_world:, predicate_body:, title:, polling:, timeout:, args: [])
12
+ def initialize(dom_world:, predicate_body:, title:, polling:, timeout:, args: [], binding_function: nil)
13
13
  if polling.is_a?(String)
14
14
  if polling != 'raf' && polling != 'mutation'
15
15
  raise ArgumentError.new("Unknown polling option: #{polling}")
@@ -27,8 +27,12 @@ class Puppeteer::WaitTask
27
27
  @timeout = timeout
28
28
  @predicate_body = "return (#{predicate_body})(...args);"
29
29
  @args = args
30
+ @binding_function = binding_function
30
31
  @run_count = 0
31
- @dom_world._wait_tasks.add(self)
32
+ @dom_world.send(:_wait_tasks).add(self)
33
+ if binding_function
34
+ @dom_world.send(:_bound_functions)[binding_function.name] = binding_function
35
+ end
32
36
  @promise = resolvable_future
33
37
 
34
38
  # Since page navigation requires us to re-install the pageScript, we should track
@@ -53,8 +57,16 @@ class Puppeteer::WaitTask
53
57
 
54
58
  def rerun
55
59
  run_count = (@run_count += 1)
60
+ context = @dom_world.execution_context
61
+
62
+ return if @terminated || run_count != @run_count
63
+ if @binding_function
64
+ @dom_world.add_binding_to_context(context, @binding_function)
65
+ end
66
+ return if @terminated || run_count != @run_count
67
+
56
68
  begin
57
- success = @dom_world.execution_context.evaluate_handle(
69
+ success = context.evaluate_handle(
58
70
  WAIT_FOR_PREDICATE_PAGE_FUNCTION,
59
71
  @predicate_body,
60
72
  @polling,
@@ -103,7 +115,7 @@ class Puppeteer::WaitTask
103
115
 
104
116
  private def cleanup
105
117
  @timeout_cleared = true
106
- @dom_world._wait_tasks.delete(self)
118
+ @dom_world.send(:_wait_tasks).delete(self)
107
119
  end
108
120
 
109
121
  private define_async_method :async_rerun
@@ -27,7 +27,7 @@ Gem::Specification.new do |spec|
27
27
  spec.add_development_dependency 'rake', '~> 13.0.3'
28
28
  spec.add_development_dependency 'rspec', '~> 3.10.0 '
29
29
  spec.add_development_dependency 'rspec_junit_formatter' # for CircleCI.
30
- spec.add_development_dependency 'rubocop', '~> 1.8.0'
30
+ spec.add_development_dependency 'rubocop', '~> 1.10.0'
31
31
  spec.add_development_dependency 'rubocop-rspec'
32
32
  spec.add_development_dependency 'sinatra'
33
33
  spec.add_development_dependency 'webrick'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: puppeteer-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.28.1
4
+ version: 0.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - YusukeIwaki
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-01-10 00:00:00.000000000 Z
11
+ date: 2021-02-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -142,14 +142,14 @@ dependencies:
142
142
  requirements:
143
143
  - - "~>"
144
144
  - !ruby/object:Gem::Version
145
- version: 1.8.0
145
+ version: 1.10.0
146
146
  type: :development
147
147
  prerelease: false
148
148
  version_requirements: !ruby/object:Gem::Requirement
149
149
  requirements:
150
150
  - - "~>"
151
151
  - !ruby/object:Gem::Version
152
- version: 1.8.0
152
+ version: 1.10.0
153
153
  - !ruby/object:Gem::Dependency
154
154
  name: rubocop-rspec
155
155
  requirement: !ruby/object:Gem::Requirement
@@ -215,6 +215,7 @@ extra_rdoc_files: []
215
215
  files:
216
216
  - ".circleci/config.yml"
217
217
  - ".github/ISSUE_TEMPLATE/bug_report.md"
218
+ - ".github/ISSUE_TEMPLATE/feature_request.md"
218
219
  - ".github/stale.yml"
219
220
  - ".github/workflows/docs.yml"
220
221
  - ".github/workflows/reviewdog.yml"
@@ -231,6 +232,7 @@ files:
231
232
  - bin/setup
232
233
  - docker-compose.yml
233
234
  - lib/puppeteer.rb
235
+ - lib/puppeteer/aria_query_handler.rb
234
236
  - lib/puppeteer/browser.rb
235
237
  - lib/puppeteer/browser_context.rb
236
238
  - lib/puppeteer/browser_fetcher.rb
@@ -239,6 +241,7 @@ files:
239
241
  - lib/puppeteer/concurrent_ruby_utils.rb
240
242
  - lib/puppeteer/connection.rb
241
243
  - lib/puppeteer/console_message.rb
244
+ - lib/puppeteer/custom_query_handler.rb
242
245
  - lib/puppeteer/debug_print.rb
243
246
  - lib/puppeteer/define_async_method.rb
244
247
  - lib/puppeteer/device.rb
@@ -279,6 +282,7 @@ files:
279
282
  - lib/puppeteer/page/pdf_options.rb
280
283
  - lib/puppeteer/page/screenshot_options.rb
281
284
  - lib/puppeteer/page/screenshot_task_queue.rb
285
+ - lib/puppeteer/query_handler_manager.rb
282
286
  - lib/puppeteer/remote_object.rb
283
287
  - lib/puppeteer/request.rb
284
288
  - lib/puppeteer/response.rb