puppeteer-ruby 0.0.27 → 0.31.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/puppeteer.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require 'concurrent'
2
2
 
3
- class Puppeteer; end
3
+ module Puppeteer; end
4
4
 
5
5
  require 'puppeteer/env'
6
6
 
@@ -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,8 @@ require 'puppeteer/lifecycle_watcher'
41
43
  require 'puppeteer/mouse'
42
44
  require 'puppeteer/network_manager'
43
45
  require 'puppeteer/page'
46
+ require 'puppeteer/puppeteer'
47
+ require 'puppeteer/query_handler_manager'
44
48
  require 'puppeteer/remote_object'
45
49
  require 'puppeteer/request'
46
50
  require 'puppeteer/response'
@@ -56,9 +60,9 @@ require 'puppeteer/web_socket_transport'
56
60
  require 'puppeteer/element_handle'
57
61
 
58
62
  # ref: https://github.com/puppeteer/puppeteer/blob/master/lib/Puppeteer.js
59
- class Puppeteer
60
- def self.method_missing(method, *args, **kwargs, &block)
61
- @puppeteer ||= Puppeteer.new(
63
+ module Puppeteer
64
+ module_function def method_missing(method, *args, **kwargs, &block)
65
+ @puppeteer ||= ::Puppeteer::Puppeteer.new(
62
66
  project_root: __dir__,
63
67
  preferred_revision: '706915',
64
68
  is_puppeteer_core: true,
@@ -70,167 +74,4 @@ class Puppeteer
70
74
  @puppeteer.public_send(method, *args, **kwargs, &block)
71
75
  end
72
76
  end
73
-
74
- # @param project_root [String]
75
- # @param prefereed_revision [String]
76
- # @param is_puppeteer_core [String]
77
- def initialize(project_root:, preferred_revision:, is_puppeteer_core:)
78
- @project_root = project_root
79
- @preferred_revision = preferred_revision
80
- @is_puppeteer_core = is_puppeteer_core
81
- end
82
-
83
- # @param product [String]
84
- # @param executable_path [String]
85
- # @param ignore_default_args [Array<String>|nil]
86
- # @param handle_SIGINT [Boolean]
87
- # @param handle_SIGTERM [Boolean]
88
- # @param handle_SIGHUP [Boolean]
89
- # @param timeout [Integer]
90
- # @param dumpio [Boolean]
91
- # @param env [Hash]
92
- # @param pipe [Boolean]
93
- # @param args [Array<String>]
94
- # @param user_data_dir [String]
95
- # @param devtools [Boolean]
96
- # @param headless [Boolean]
97
- # @param ignore_https_errors [Boolean]
98
- # @param default_viewport [Puppeteer::Viewport|nil]
99
- # @param slow_mo [Integer]
100
- # @return [Puppeteer::Browser]
101
- def launch(
102
- product: nil,
103
- executable_path: nil,
104
- ignore_default_args: nil,
105
- handle_SIGINT: nil,
106
- handle_SIGTERM: nil,
107
- handle_SIGHUP: nil,
108
- timeout: nil,
109
- dumpio: nil,
110
- env: nil,
111
- pipe: nil,
112
- args: nil,
113
- user_data_dir: nil,
114
- devtools: nil,
115
- headless: nil,
116
- ignore_https_errors: nil,
117
- default_viewport: nil,
118
- slow_mo: nil
119
- )
120
- options = {
121
- executable_path: executable_path,
122
- ignore_default_args: ignore_default_args,
123
- handle_SIGINT: handle_SIGINT,
124
- handle_SIGTERM: handle_SIGTERM,
125
- handle_SIGHUP: handle_SIGHUP,
126
- timeout: timeout,
127
- dumpio: dumpio,
128
- env: env,
129
- pipe: pipe,
130
- args: args,
131
- user_data_dir: user_data_dir,
132
- devtools: devtools,
133
- headless: headless,
134
- ignore_https_errors: ignore_https_errors,
135
- default_viewport: default_viewport,
136
- slow_mo: slow_mo,
137
- }
138
-
139
- @product_name ||= product
140
- browser = launcher.launch(options)
141
- if block_given?
142
- begin
143
- yield(browser)
144
- ensure
145
- browser.close
146
- end
147
- else
148
- browser
149
- end
150
- end
151
-
152
- # @param browser_ws_endpoint [String]
153
- # @param browser_url [String]
154
- # @param transport [Puppeteer::WebSocketTransport]
155
- # @param ignore_https_errors [Boolean]
156
- # @param default_viewport [Puppeteer::Viewport|nil]
157
- # @param slow_mo [Integer]
158
- # @return [Puppeteer::Browser]
159
- def connect(
160
- browser_ws_endpoint: nil,
161
- browser_url: nil,
162
- transport: nil,
163
- ignore_https_errors: nil,
164
- default_viewport: nil,
165
- slow_mo: nil
166
- )
167
- options = {
168
- browser_ws_endpoint: browser_ws_endpoint,
169
- browser_url: browser_url,
170
- transport: transport,
171
- ignore_https_errors: ignore_https_errors,
172
- default_viewport: default_viewport,
173
- slow_mo: slow_mo,
174
- }.compact
175
- browser = launcher.connect(options)
176
- if block_given?
177
- begin
178
- yield(browser)
179
- ensure
180
- browser.disconnect
181
- end
182
- else
183
- browser
184
- end
185
- end
186
-
187
- # @return [String]
188
- def executable_path
189
- launcher.executable_path
190
- end
191
-
192
- private def launcher
193
- @launcher ||= Puppeteer::Launcher.new(
194
- project_root: @project_root,
195
- preferred_revision: @preferred_revision,
196
- is_puppeteer_core: @is_puppeteer_core,
197
- product: @product_name,
198
- )
199
- end
200
-
201
- # @return [String]
202
- def product
203
- launcher.product
204
- end
205
-
206
- # @return [Puppeteer::Devices]
207
- def devices
208
- Puppeteer::Devices
209
- end
210
-
211
- # # @return {Object}
212
- # def errors
213
- # # ???
214
- # end
215
-
216
- # @param args [Array<String>]
217
- # @param user_data_dir [String]
218
- # @param devtools [Boolean]
219
- # @param headless [Boolean]
220
- # @return [Array<String>]
221
- def default_args(args: nil, user_data_dir: nil, devtools: nil, headless: nil)
222
- options = {
223
- args: args,
224
- user_data_dir: user_data_dir,
225
- devtools: devtools,
226
- headless: headless,
227
- }.compact
228
- launcher.default_args(options)
229
- end
230
-
231
- # @param {!BrowserFetcher.Options=} options
232
- # @return {!BrowserFetcher}
233
- def createBrowserFetcher(options = {})
234
- BrowserFetcher.new(@project_root, options)
235
- end
236
77
  end
@@ -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
@@ -111,7 +111,7 @@ class Puppeteer::BrowserRunner
111
111
  end
112
112
  end
113
113
 
114
- if @launch_options.handle_SIGHUP?
114
+ if @launch_options.handle_SIGHUP? && !Puppeteer.env.windows?
115
115
  trap(:HUP) do
116
116
  close
117
117
  end
@@ -273,6 +273,16 @@ class Puppeteer::Connection
273
273
  def create_session(target_info)
274
274
  result = send_message('Target.attachToTarget', targetId: target_info.target_id, flatten: true)
275
275
  session_id = result['sessionId']
276
- @sessions[session_id]
276
+
277
+ # Target.attachedToTarget is often notified after the result of Target.attachToTarget.
278
+ # D, [2020-04-04T23:04:30.736311 #91875] DEBUG -- : RECV << {"id"=>2, "result"=>{"sessionId"=>"DA002F8A95B04710502CB40D8430B95A"}}
279
+ # D, [2020-04-04T23:04:30.736649 #91875] DEBUG -- : RECV << {"method"=>"Target.attachedToTarget", "params"=>{"sessionId"=>"DA002F8A95B04710502CB40D8430B95A", "targetInfo"=>{"targetId"=>"EBAB949A7DE63F12CB94268AD3A9976B", "type"=>"page", "title"=>"about:blank", "url"=>"about:blank", "attached"=>true, "browserContextId"=>"46D23767E9B79DD9E589101121F6DADD"}, "waitingForDebugger"=>false}}
280
+ # So we have to wait for "Target.attachedToTarget" a bit.
281
+ 20.times do
282
+ if @sessions[session_id]
283
+ return @sessions[session_id]
284
+ end
285
+ sleep 0.1
286
+ end
277
287
  end
278
288
  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
@@ -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