puppeteer-ruby 0.28.1 → 0.29.0

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