puppeteer-ruby 0.0.26 → 0.31.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -10,12 +10,21 @@ module Puppeteer::DefineAsyncMethod
10
10
  end
11
11
 
12
12
  original_method = instance_method(async_method_name[6..-1])
13
- define_method(async_method_name) do |*args|
14
- Concurrent::Promises.future do
15
- original_method.bind(self).call(*args)
16
- rescue => err
17
- Logger.new($stderr).warn(err)
18
- raise err
13
+ define_method(async_method_name) do |*args, **kwargs|
14
+ if kwargs.empty? # for Ruby < 2.7
15
+ Concurrent::Promises.future do
16
+ original_method.bind(self).call(*args)
17
+ rescue => err
18
+ Logger.new($stderr).warn(err)
19
+ raise err
20
+ end
21
+ else
22
+ Concurrent::Promises.future do
23
+ original_method.bind(self).call(*args, **kwargs)
24
+ rescue => err
25
+ Logger.new($stderr).warn(err)
26
+ raise err
27
+ end
19
28
  end
20
29
  end
21
30
  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
@@ -75,12 +126,13 @@ class Puppeteer::DOMWorld
75
126
  execution_context.evaluate(page_function, *args)
76
127
  end
77
128
 
78
- # `$()` in JavaScript. $ is not allowed to use as a method name in Ruby.
129
+ # `$()` in JavaScript.
79
130
  # @param {string} selector
80
131
  # @return {!Promise<?Puppeteer.ElementHandle>}
81
- def S(selector)
82
- document.S(selector)
132
+ def query_selector(selector)
133
+ document.query_selector(selector)
83
134
  end
135
+ alias_method :S, :query_selector
84
136
 
85
137
  private def evaluate_document
86
138
  # sometimes execution_context.evaluate_handle('document') returns null object.
@@ -107,30 +159,33 @@ class Puppeteer::DOMWorld
107
159
  document.Sx(expression)
108
160
  end
109
161
 
110
- # `$eval()` in JavaScript. $ is not allowed to use as a method name in Ruby.
162
+ # `$eval()` in JavaScript.
111
163
  # @param {string} selector
112
164
  # @param {Function|string} pageFunction
113
165
  # @param {!Array<*>} args
114
166
  # @return {!Promise<(!Object|undefined)>}
115
- def Seval(selector, page_function, *args)
116
- document.Seval(selector, page_function, *args)
167
+ def eval_on_selector(selector, page_function, *args)
168
+ document.eval_on_selector(selector, page_function, *args)
117
169
  end
170
+ alias_method :Seval, :eval_on_selector
118
171
 
119
- # `$$eval()` in JavaScript. $ is not allowed to use as a method name in Ruby.
172
+ # `$$eval()` in JavaScript.
120
173
  # @param {string} selector
121
174
  # @param {Function|string} pageFunction
122
175
  # @param {!Array<*>} args
123
176
  # @return {!Promise<(!Object|undefined)>}
124
- def SSeval(selector, page_function, *args)
125
- document.SSeval(selector, page_function, *args)
177
+ def eval_on_selector_all(selector, page_function, *args)
178
+ document.eval_on_selector_all(selector, page_function, *args)
126
179
  end
180
+ alias_method :SSeval, :eval_on_selector_all
127
181
 
128
- # `$$()` in JavaScript. $ is not allowed to use as a method name in Ruby.
182
+ # `$$()` in JavaScript.
129
183
  # @param {string} selector
130
184
  # @return {!Promise<!Array<!Puppeteer.ElementHandle>>}
131
- def SS(selector)
132
- document.SS(selector)
185
+ def query_selector_all(selector)
186
+ document.query_selector_all(selector)
133
187
  end
188
+ alias_method :SS, :query_selector_all
134
189
 
135
190
  # @return [String]
136
191
  def content
@@ -175,144 +230,127 @@ class Puppeteer::DOMWorld
175
230
  end
176
231
  end
177
232
 
178
- # /**
179
- # * @param {!{url?: string, path?: string, content?: string, type?: string}} options
180
- # * @return {!Promise<!Puppeteer.ElementHandle>}
181
- # */
182
- # async addScriptTag(options) {
183
- # const {
184
- # url = null,
185
- # path = null,
186
- # content = null,
187
- # type = ''
188
- # } = options;
189
- # if (url !== null) {
190
- # try {
191
- # const context = await this.executionContext();
192
- # return (await context.evaluateHandle(addScriptUrl, url, type)).asElement();
193
- # } catch (error) {
194
- # throw new Error(`Loading script from ${url} failed`);
195
- # }
196
- # }
197
-
198
- # if (path !== null) {
199
- # let contents = await readFileAsync(path, 'utf8');
200
- # contents += '//# sourceURL=' + path.replace(/\n/g, '');
201
- # const context = await this.executionContext();
202
- # return (await context.evaluateHandle(addScriptContent, contents, type)).asElement();
203
- # }
204
-
205
- # if (content !== null) {
206
- # const context = await this.executionContext();
207
- # return (await context.evaluateHandle(addScriptContent, content, type)).asElement();
208
- # }
209
-
210
- # throw new Error('Provide an object with a `url`, `path` or `content` property');
211
-
212
- # /**
213
- # * @param {string} url
214
- # * @param {string} type
215
- # * @return {!Promise<!HTMLElement>}
216
- # */
217
- # async function addScriptUrl(url, type) {
218
- # const script = document.createElement('script');
219
- # script.src = url;
220
- # if (type)
221
- # script.type = type;
222
- # const promise = new Promise((res, rej) => {
223
- # script.onload = res;
224
- # script.onerror = rej;
225
- # });
226
- # document.head.appendChild(script);
227
- # await promise;
228
- # return script;
229
- # }
230
-
231
- # /**
232
- # * @param {string} content
233
- # * @param {string} type
234
- # * @return {!HTMLElement}
235
- # */
236
- # function addScriptContent(content, type = 'text/javascript') {
237
- # const script = document.createElement('script');
238
- # script.type = type;
239
- # script.text = content;
240
- # let error = null;
241
- # script.onerror = e => error = e;
242
- # document.head.appendChild(script);
243
- # if (error)
244
- # throw error;
245
- # return script;
246
- # }
247
- # }
233
+ # @param url [String?]
234
+ # @param path [String?]
235
+ # @param content [String?]
236
+ # @param type [String?]
237
+ def add_script_tag(url: nil, path: nil, content: nil, type: nil)
238
+ if url
239
+ begin
240
+ return execution_context.
241
+ evaluate_handle(ADD_SCRIPT_URL, url, type || '').
242
+ as_element
243
+ rescue Puppeteer::ExecutionContext::EvaluationError # for Chrome
244
+ raise "Loading script from #{url} failed"
245
+ rescue Puppeteer::Connection::ProtocolError # for Firefox
246
+ raise "Loading script from #{url} failed"
247
+ end
248
+ end
248
249
 
249
- # /**
250
- # * @param {!{url?: string, path?: string, content?: string}} options
251
- # * @return {!Promise<!Puppeteer.ElementHandle>}
252
- # */
253
- # async addStyleTag(options) {
254
- # const {
255
- # url = null,
256
- # path = null,
257
- # content = null
258
- # } = options;
259
- # if (url !== null) {
260
- # try {
261
- # const context = await this.executionContext();
262
- # return (await context.evaluateHandle(addStyleUrl, url)).asElement();
263
- # } catch (error) {
264
- # throw new Error(`Loading style from ${url} failed`);
265
- # }
266
- # }
267
-
268
- # if (path !== null) {
269
- # let contents = await readFileAsync(path, 'utf8');
270
- # contents += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/';
271
- # const context = await this.executionContext();
272
- # return (await context.evaluateHandle(addStyleContent, contents)).asElement();
273
- # }
274
-
275
- # if (content !== null) {
276
- # const context = await this.executionContext();
277
- # return (await context.evaluateHandle(addStyleContent, content)).asElement();
278
- # }
279
-
280
- # throw new Error('Provide an object with a `url`, `path` or `content` property');
281
-
282
- # /**
283
- # * @param {string} url
284
- # * @return {!Promise<!HTMLElement>}
285
- # */
286
- # async function addStyleUrl(url) {
287
- # const link = document.createElement('link');
288
- # link.rel = 'stylesheet';
289
- # link.href = url;
290
- # const promise = new Promise((res, rej) => {
291
- # link.onload = res;
292
- # link.onerror = rej;
293
- # });
294
- # document.head.appendChild(link);
295
- # await promise;
296
- # return link;
297
- # }
298
-
299
- # /**
300
- # * @param {string} content
301
- # * @return {!Promise<!HTMLElement>}
302
- # */
303
- # async function addStyleContent(content) {
304
- # const style = document.createElement('style');
305
- # style.type = 'text/css';
306
- # style.appendChild(document.createTextNode(content));
307
- # const promise = new Promise((res, rej) => {
308
- # style.onload = res;
309
- # style.onerror = rej;
310
- # });
311
- # document.head.appendChild(style);
312
- # await promise;
313
- # return style;
314
- # }
315
- # }
250
+ if path
251
+ contents = File.read(path)
252
+ contents += "//# sourceURL=#{path.gsub(/\n/, '')}"
253
+ return execution_context.
254
+ evaluate_handle(ADD_SCRIPT_CONTENT, contents, type || '').
255
+ as_element
256
+ end
257
+
258
+ if content
259
+ return execution_context.
260
+ evaluate_handle(ADD_SCRIPT_CONTENT, content, type || '').
261
+ as_element
262
+ end
263
+
264
+ raise ArgumentError.new('Provide an object with a `url`, `path` or `content` property')
265
+ end
266
+
267
+ ADD_SCRIPT_URL = <<~JAVASCRIPT
268
+ async (url, type) => {
269
+ const script = document.createElement('script');
270
+ script.src = url;
271
+ if (type)
272
+ script.type = type;
273
+ const promise = new Promise((res, rej) => {
274
+ script.onload = res;
275
+ script.onerror = rej;
276
+ });
277
+ document.head.appendChild(script);
278
+ await promise;
279
+ return script;
280
+ }
281
+ JAVASCRIPT
282
+
283
+ ADD_SCRIPT_CONTENT = <<~JAVASCRIPT
284
+ (content, type) => {
285
+ if (type === undefined) type = 'text/javascript';
286
+ const script = document.createElement('script');
287
+ script.type = type;
288
+ script.text = content;
289
+ let error = null;
290
+ script.onerror = e => error = e;
291
+ document.head.appendChild(script);
292
+ if (error)
293
+ throw error;
294
+ return script;
295
+ }
296
+ JAVASCRIPT
297
+
298
+ # @param url [String?]
299
+ # @param path [String?]
300
+ # @param content [String?]
301
+ def add_style_tag(url: nil, path: nil, content: nil)
302
+ if url
303
+ begin
304
+ return execution_context.evaluate_handle(ADD_STYLE_URL, url).as_element
305
+ rescue Puppeteer::ExecutionContext::EvaluationError # for Chrome
306
+ raise "Loading style from #{url} failed"
307
+ rescue Puppeteer::Connection::ProtocolError # for Firefox
308
+ raise "Loading style from #{url} failed"
309
+ end
310
+ end
311
+
312
+ if path
313
+ contents = File.read(path)
314
+ contents += "/*# sourceURL=#{path.gsub(/\n/, '')}*/"
315
+ return execution_context.evaluate_handle(ADD_STYLE_CONTENT, contents).as_element
316
+ end
317
+
318
+ if content
319
+ return execution_context.evaluate_handle(ADD_STYLE_CONTENT, content).as_element
320
+ end
321
+
322
+ raise ArgumentError.new('Provide an object with a `url`, `path` or `content` property')
323
+ end
324
+
325
+ ADD_STYLE_URL = <<~JAVASCRIPT
326
+ async (url) => {
327
+ const link = document.createElement('link');
328
+ link.rel = 'stylesheet';
329
+ link.href = url;
330
+ const promise = new Promise((res, rej) => {
331
+ link.onload = res;
332
+ link.onerror = rej;
333
+ });
334
+ document.head.appendChild(link);
335
+ await promise;
336
+ return link;
337
+ }
338
+ JAVASCRIPT
339
+
340
+ ADD_STYLE_CONTENT = <<~JAVASCRIPT
341
+ async (content) => {
342
+ const style = document.createElement('style');
343
+ style.type = 'text/css';
344
+ style.appendChild(document.createTextNode(content));
345
+ const promise = new Promise((res, rej) => {
346
+ style.onload = res;
347
+ style.onerror = rej;
348
+ });
349
+ document.head.appendChild(style);
350
+ await promise;
351
+ return style;
352
+ }
353
+ JAVASCRIPT
316
354
 
317
355
  class ElementNotFoundError < StandardError
318
356
  def initialize(selector)
@@ -325,14 +363,14 @@ class Puppeteer::DOMWorld
325
363
  # @param button [String] "left"|"right"|"middle"
326
364
  # @param click_count [Number]
327
365
  def click(selector, delay: nil, button: nil, click_count: nil)
328
- handle = S(selector) or raise ElementNotFoundError.new(selector)
366
+ handle = query_selector(selector) or raise ElementNotFoundError.new(selector)
329
367
  handle.click(delay: delay, button: button, click_count: click_count)
330
368
  handle.dispose
331
369
  end
332
370
 
333
371
  # @param selector [String]
334
372
  def focus(selector)
335
- handle = S(selector) or raise ElementNotFoundError.new(selector)
373
+ handle = query_selector(selector) or raise ElementNotFoundError.new(selector)
336
374
  handle.focus
337
375
  handle.dispose
338
376
  end
@@ -350,7 +388,7 @@ class Puppeteer::DOMWorld
350
388
  # @param selector [String]
351
389
  # @return [Array<String>]
352
390
  def select(selector, *values)
353
- handle = S(selector) or raise ElementNotFoundError.new(selector)
391
+ handle = query_selector(selector) or raise ElementNotFoundError.new(selector)
354
392
  result = handle.select(*values)
355
393
  handle.dispose
356
394
 
@@ -359,7 +397,7 @@ class Puppeteer::DOMWorld
359
397
 
360
398
  # @param selector [String]
361
399
  def tap(selector)
362
- handle = S(selector) or raise ElementNotFoundError.new(selector)
400
+ handle = query_selector(selector) or raise ElementNotFoundError.new(selector)
363
401
  handle.tap
364
402
  handle.dispose
365
403
  end
@@ -368,7 +406,7 @@ class Puppeteer::DOMWorld
368
406
  # @param text [String]
369
407
  # @param delay [Number]
370
408
  def type_text(selector, text, delay: nil)
371
- handle = S(selector) or raise ElementNotFoundError.new(selector)
409
+ handle = query_selector(selector) or raise ElementNotFoundError.new(selector)
372
410
  handle.type_text(text, delay: delay)
373
411
  handle.dispose
374
412
  end
@@ -378,61 +416,127 @@ class Puppeteer::DOMWorld
378
416
  # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
379
417
  # @param timeout [Integer]
380
418
  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)
419
+ # call wait_for_selector_in_page with custom query selector.
420
+ query_selector_manager = Puppeteer::QueryHandlerManager.instance
421
+ query_selector_manager.detect_query_handler(selector).wait_for(self, visible: visible, hidden: hidden, timeout: timeout)
382
422
  end
383
423
 
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)
424
+ private def binding_identifier(name, context)
425
+ "#{name}_#{context.send(:_context_id)}"
390
426
  end
391
427
 
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
428
 
405
- # @param page_function [String]
406
- # @param args [Array]
407
- # @param polling [Integer|String]
429
+ def add_binding_to_context(context, binding_function)
430
+ return if @ctx_bindings.include?(binding_identifier(binding_function.name, context))
431
+
432
+ expression = binding_function.page_binding_init_string
433
+ begin
434
+ context.client.send_message('Runtime.addBinding',
435
+ name: binding_function.name,
436
+ executionContextName: context.send(:_context_name))
437
+ context.evaluate(expression, 'internal', binding_function.name)
438
+ rescue => err
439
+ # We could have tried to evaluate in a context which was already
440
+ # destroyed. This happens, for example, if the page is navigated while
441
+ # we are trying to add the binding
442
+ allowed = [
443
+ 'Execution context was destroyed',
444
+ 'Cannot find context with specified id',
445
+ ]
446
+ if allowed.any? { |msg| err.message.include?(msg) }
447
+ # ignore
448
+ else
449
+ raise
450
+ end
451
+ end
452
+ @ctx_bindings << binding_identifier(binding_function.name, context)
453
+ end
454
+
455
+ private def handle_binding_called(event)
456
+ return unless has_context?
457
+ payload = JSON.parse(event['payload']) rescue nil
458
+ name = payload['name']
459
+ args = payload['args']
460
+
461
+ # The binding was either called by something in the page or it was
462
+ # called before our wrapper was initialized.
463
+ return unless payload
464
+ return unless payload['type'] == 'internal'
465
+ context = execution_context
466
+ return unless @ctx_bindings.include?(binding_identifier(name, context))
467
+ return unless context.send(:_context_id) == event['executionContextId']
468
+
469
+ result = @bound_functions[name].call(*args)
470
+ deliver_result_js = <<~JAVASCRIPT
471
+ (name, seq, result) => {
472
+ globalThis[name].callbacks.get(seq).resolve(result);
473
+ globalThis[name].callbacks.delete(seq);
474
+ }
475
+ JAVASCRIPT
476
+
477
+ begin
478
+ context.evaluate(deliver_result_js, name, payload['seq'], result)
479
+ rescue => err
480
+ # The WaitTask may already have been resolved by timing out, or the
481
+ # exection context may have been destroyed.
482
+ # In both caes, the promises above are rejected with a protocol error.
483
+ # We can safely ignores these, as the WaitTask is re-installed in
484
+ # the next execution context if needed.
485
+ return if err.message.include?('Protocol error')
486
+ raise
487
+ end
488
+ end
489
+
490
+ # @param query_one [String] JS function (element: Element | Document, selector: string) => Element | null;
491
+ # @param selector [String]
492
+ # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
493
+ # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
408
494
  # @param timeout [Integer]
409
- # @return [Puppeteer::JSHandle]
410
- def wait_for_function(page_function, args: [], polling: nil, timeout: nil)
411
- option_polling = polling || 'raf'
495
+ private def wait_for_selector_in_page(query_one, selector, visible: nil, hidden: nil, timeout: nil, binding_function: nil)
496
+ option_wait_for_visible = visible || false
497
+ option_wait_for_hidden = hidden || false
412
498
  option_timeout = timeout || @timeout_settings.timeout
413
499
 
414
- Puppeteer::WaitTask.new(
500
+ polling =
501
+ if option_wait_for_visible || option_wait_for_hidden
502
+ 'raf'
503
+ else
504
+ 'mutation'
505
+ end
506
+ title = "selector #{selector}#{option_wait_for_hidden ? 'to be hidden' : ''}"
507
+
508
+ selector_predicate = make_predicate_string(
509
+ predicate_arg_def: '(selector, waitForVisible, waitForHidden)',
510
+ predicate_query_handler: query_one,
511
+ async: true,
512
+ predicate_body: <<~JAVASCRIPT
513
+ const node = await predicateQueryHandler(document, selector)
514
+ return checkWaitForOptions(node, waitForVisible, waitForHidden);
515
+ JAVASCRIPT
516
+ )
517
+
518
+ wait_task = Puppeteer::WaitTask.new(
415
519
  dom_world: self,
416
- predicate_body: page_function,
417
- title: 'function',
418
- polling: option_polling,
520
+ predicate_body: selector_predicate,
521
+ title: title,
522
+ polling: polling,
419
523
  timeout: option_timeout,
420
- args: args,
421
- ).await_promise
422
- end
423
-
424
-
425
- # @return [String]
426
- def title
427
- evaluate('() => document.title')
524
+ args: [selector, option_wait_for_visible, option_wait_for_hidden],
525
+ binding_function: binding_function,
526
+ )
527
+ handle = wait_task.await_promise
528
+ unless handle.as_element
529
+ handle.dispose
530
+ return nil
531
+ end
532
+ handle.as_element
428
533
  end
429
534
 
430
- # @param selector_or_xpath [String]
431
- # @param is_xpath [Boolean]
535
+ # @param xpath [String]
432
536
  # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
433
537
  # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
434
538
  # @param timeout [Integer]
435
- private def wait_for_selector_or_xpath(selector_or_xpath, is_xpath, visible: nil, hidden: nil, timeout: nil)
539
+ def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil)
436
540
  option_wait_for_visible = visible || false
437
541
  option_wait_for_hidden = hidden || false
438
542
  option_timeout = timeout || @timeout_settings.timeout
@@ -443,15 +547,23 @@ class Puppeteer::DOMWorld
443
547
  else
444
548
  'mutation'
445
549
  end
446
- title = "#{is_xpath ? :XPath : :selector} #{selector_or_xpath}#{option_wait_for_hidden ? 'to be hidden' : ''}"
550
+ title = "XPath #{xpath}#{option_wait_for_hidden ? 'to be hidden' : ''}"
551
+
552
+ xpath_predicate = make_predicate_string(
553
+ predicate_arg_def: '(selector, waitForVisible, waitForHidden)',
554
+ predicate_body: <<~JAVASCRIPT
555
+ const node = document.evaluate(selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
556
+ return checkWaitForOptions(node, waitForVisible, waitForHidden);
557
+ JAVASCRIPT
558
+ )
447
559
 
448
560
  wait_task = Puppeteer::WaitTask.new(
449
561
  dom_world: self,
450
- predicate_body: PREDICATE,
562
+ predicate_body: xpath_predicate,
451
563
  title: title,
452
564
  polling: polling,
453
565
  timeout: option_timeout,
454
- args: [selector_or_xpath, is_xpath, option_wait_for_visible, option_wait_for_hidden],
566
+ args: [xpath, option_wait_for_visible, option_wait_for_hidden],
455
567
  )
456
568
  handle = wait_task.await_promise
457
569
  unless handle.as_element
@@ -461,34 +573,66 @@ class Puppeteer::DOMWorld
461
573
  handle.as_element
462
574
  end
463
575
 
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
576
+ # @param page_function [String]
577
+ # @param args [Array]
578
+ # @param polling [Integer|String]
579
+ # @param timeout [Integer]
580
+ # @return [Puppeteer::JSHandle]
581
+ def wait_for_function(page_function, args: [], polling: nil, timeout: nil)
582
+ option_polling = polling || 'raf'
583
+ option_timeout = timeout || @timeout_settings.timeout
584
+
585
+ Puppeteer::WaitTask.new(
586
+ dom_world: self,
587
+ predicate_body: page_function,
588
+ title: 'function',
589
+ polling: option_polling,
590
+ timeout: option_timeout,
591
+ args: args,
592
+ ).await_promise
593
+ end
594
+
595
+
596
+ # @return [String]
597
+ def title
598
+ evaluate('() => document.title')
599
+ end
600
+
601
+ private def make_predicate_string(predicate_arg_def:, predicate_body:, predicate_query_handler: nil, async: false)
602
+ predicate_query_handler_string =
603
+ if predicate_query_handler
604
+ "const predicateQueryHandler = #{predicate_query_handler}"
605
+ else
606
+ ""
607
+ end
608
+
609
+ <<~JAVASCRIPT
610
+ #{async ? 'async ' : ''}function _#{predicate_arg_def} {
611
+ #{predicate_query_handler_string}
612
+ #{predicate_body}
613
+
614
+ function checkWaitForOptions(node, waitForVisible, waitForHidden) {
615
+ if (!node) return waitForHidden;
616
+ if (!waitForVisible && !waitForHidden) return node;
617
+ const element =
618
+ node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
619
+
620
+ const style = window.getComputedStyle(element);
621
+ const isVisible =
622
+ style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
623
+ const success =
624
+ waitForVisible === isVisible || waitForHidden === !isVisible;
625
+ return success ? node : null;
626
+
627
+ /**
628
+ * @return {boolean}
629
+ */
630
+ function hasVisibleBoundingBox() {
631
+ const rect = element.getBoundingClientRect();
632
+ return !!(rect.top || rect.bottom || rect.width || rect.height);
633
+ }
634
+ }
635
+ }
636
+ JAVASCRIPT
637
+ end
494
638
  end