puppeteer-ruby 0.0.27 → 0.31.1

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