puppeteer-ruby 0.43.0 → 0.44.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da17d477ad97a3197fa0eacf237e784204439efdca104e2a304d5cfb58c449d3
4
- data.tar.gz: f56c151a8d7ebd8ac0ff71feafe5a90dcdd14ed997aaa9ea8e198ddd345b93dd
3
+ metadata.gz: 6ade25ba8f8e99240ed29fdad9300af7b65cc12ddf09e45c43f7b672c6324ed4
4
+ data.tar.gz: 97d3bfc48b3a2e3729dd8857c2bb6deca7babd4d0b055195f2dbe7c01c941e97
5
5
  SHA512:
6
- metadata.gz: 3db33eea6388dd6c743395c9e3cd583d95a3f2147627ae371390e40bf6269f32d689687e8adf0fdaef8b99134b6bad2ee55061ca66c037407ec681ee62b61188
7
- data.tar.gz: 2b169890fd8c5dde6850fff2439e5d43c03fa1b65e906ce443389c0599cf31d0e88af1ca0203c12cff90b2789c14af7a992d1c6b95caf7e78b9dadbf4d4a0f09
6
+ metadata.gz: 891d8c29ac63d25cdb34178024d7a9202675d77b121a903e24a51e9465e46a9b7a0a9b70a2d893d9b274724b5547d5bb90e133aa2c217052f3f523f952e1a0a3
7
+ data.tar.gz: eea1fb6ff2f179208932a3bb84ca96841b1b08ff588d914e88ace6ad9e31f71c567c21321e3376b019a265122e51a4352b3f0039dd6bea770dba2a383efe82e5
data/CHANGELOG.md CHANGED
@@ -1,7 +1,17 @@
1
- ### main [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.43.0...main)]
1
+ ### main [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.44.0...main)]
2
2
 
3
3
  - xxx
4
4
 
5
+ ### 0.44.0 [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.43.1...0.44.0)]
6
+
7
+ - Port Puppeteer v17.0-v17.1 features.
8
+ - `wait_for_selector` no longer accept `root` parameter.
9
+
10
+
11
+ ### 0.43.1 [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.43.0...0.43.1)]
12
+
13
+ - Port Puppeteer v16.1 features, including bugfix and XPath query handler.
14
+
5
15
  ### 0.43.0 [[diff](https://github.com/YusukeIwaki/puppeteer-ruby/compare/0.42.0...0.43.0)]
6
16
 
7
17
  - Port Puppeteer v16.0 features. Increasing stability.
data/docs/api_coverage.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # API coverages
2
- - Puppeteer version: v16.2.0
3
- - puppeteer-ruby version: 0.43.0
2
+ - Puppeteer version: v17.1.3
3
+ - puppeteer-ruby version: 0.44.0
4
4
 
5
5
  ## Puppeteer
6
6
 
@@ -150,13 +150,6 @@
150
150
  * ~~removeAllListeners~~
151
151
  * ~~removeListener~~
152
152
 
153
- ## ExecutionContext
154
-
155
- * evaluate
156
- * evaluateHandle => `#evaluate_handle`
157
- * frame
158
- * ~~queryObjects~~
159
-
160
153
  ## FileChooser
161
154
 
162
155
  * accept
@@ -172,12 +165,12 @@
172
165
  * $x => `#Sx`
173
166
  * addScriptTag => `#add_script_tag`
174
167
  * addStyleTag => `#add_style_tag`
168
+ * addStyleTag => `#add_style_tag`
175
169
  * childFrames => `#child_frames`
176
170
  * click
177
171
  * content
178
172
  * evaluate
179
173
  * evaluateHandle => `#evaluate_handle`
180
- * executionContext => `#execution_context`
181
174
  * focus
182
175
  * goto
183
176
  * hover
@@ -251,7 +244,6 @@
251
244
  * dispose
252
245
  * evaluate
253
246
  * evaluateHandle => `#evaluate_handle`
254
- * executionContext => `#execution_context`
255
247
  * getProperties => `#properties`
256
248
  * getProperty => `#[]`
257
249
  * getProperty => `#[]`
@@ -289,6 +281,7 @@
289
281
  * $x => `#Sx`
290
282
  * addScriptTag => `#add_script_tag`
291
283
  * addStyleTag => `#add_style_tag`
284
+ * addStyleTag => `#add_style_tag`
292
285
  * authenticate
293
286
  * bringToFront => `#bring_to_front`
294
287
  * browser
@@ -313,6 +306,7 @@
313
306
  * exposeFunction => `#expose_function`
314
307
  * focus
315
308
  * frames
309
+ * ~~getDefaultTimeout~~
316
310
  * goBack => `#go_back`
317
311
  * goForward => `#go_forward`
318
312
  * goto
@@ -401,5 +395,4 @@
401
395
 
402
396
  * ~~evaluate~~
403
397
  * ~~evaluateHandle~~
404
- * ~~executionContext~~
405
398
  * ~~url~~
@@ -25,43 +25,83 @@ class Puppeteer::AriaQueryHandler
25
25
  query_options
26
26
  end
27
27
 
28
- def query_one(element, selector)
29
- context = element.execution_context
28
+ # @param element [Puppeteer::ElementHandle]
29
+ # @param selector [String]
30
+ private def query_one_id(element, selector)
30
31
  parse_result = parse_aria_selector(selector)
31
32
  res = element.query_ax_tree(accessible_name: parse_result[:name], role: parse_result[:role])
32
- if res.empty?
33
+
34
+ if res.first.is_a?(Hash)
35
+ res.first['backendDOMNodeId']
36
+ else
33
37
  nil
38
+ end
39
+ end
40
+
41
+ def query_one(element, selector)
42
+ id = query_one_id(element, selector)
43
+
44
+ if id
45
+ element.frame.main_world.adopt_backend_node(id)
34
46
  else
35
- context.adopt_backend_node_id(res.first['backendDOMNodeId'])
47
+ nil
36
48
  end
37
49
  end
38
50
 
39
- def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil, root: nil)
51
+ def wait_for(element_or_frame, selector, visible: nil, hidden: nil, timeout: nil)
52
+ case element_or_frame
53
+ when Puppeteer::Frame
54
+ frame = element_or_frame
55
+ element = nil
56
+ when Puppeteer::ElementHandle
57
+ frame = element_or_frame.frame
58
+ element = frame.puppeteer_world.adopt_handle(element_or_frame)
59
+ else
60
+ raise ArgumentError.new("element_or_frame must be a Frame or ElementHandle. #{element_or_frame.inspect}")
61
+ end
62
+
40
63
  # addHandlerToWorld
41
- binding_function = Puppeteer::DOMWorld::BindingFunction.new(
64
+ binding_function = Puppeteer::IsolaatedWorld::BindingFunction.new(
42
65
  name: 'ariaQuerySelector',
43
- proc: -> (sel) { query_one(root || dom_world.send(:document), sel) },
66
+ proc: -> (sel) {
67
+ id = query_one_id(element || frame.puppeteer_world.document, sel)
68
+
69
+ if id
70
+ frame.puppeteer_world.adopt_backend_node(id)
71
+ else
72
+ nil
73
+ end
74
+ },
44
75
  )
45
- dom_world.send(:wait_for_selector_in_page,
76
+ result = frame.puppeteer_world.send(:wait_for_selector_in_page,
46
77
  '(_, selector) => globalThis.ariaQuerySelector(selector)',
78
+ element,
47
79
  selector,
48
80
  visible: visible,
49
81
  hidden: hidden,
50
82
  timeout: timeout,
51
83
  binding_function: binding_function,
52
- root: root,
53
84
  )
85
+
86
+ element&.dispose
87
+
88
+ if result.is_a?(Puppeteer::ElementHandle)
89
+ result.frame.main_world.transfer_handle(result)
90
+ else
91
+ result&.dispose
92
+ nil
93
+ end
54
94
  end
55
95
 
56
96
  def query_all(element, selector)
57
- context = element.execution_context
97
+ world = element.frame.main_world
58
98
  parse_result = parse_aria_selector(selector)
59
99
  res = element.query_ax_tree(accessible_name: parse_result[:name], role: parse_result[:role])
60
100
  if res.empty?
61
101
  nil
62
102
  else
63
103
  promises = res.map do |ax_node|
64
- context.send(:async_adopt_backend_node_id, ax_node['backendDOMNodeId'])
104
+ world.send(:async_adopt_backend_node, ax_node['backendDOMNodeId'])
65
105
  end
66
106
  await_all(*promises)
67
107
  end
@@ -196,7 +196,7 @@ class Puppeteer::Browser
196
196
  session: session,
197
197
  browser_context: context,
198
198
  target_manager: @target_manager,
199
- session_factory: -> { @connection.create_session(target_info) },
199
+ session_factory: -> (auto_attach_emulated) { @connection.create_session(target_info, auto_attach_emulated: auto_attach_emulated) },
200
200
  ignore_https_errors: @ignore_https_errors,
201
201
  default_viewport: @default_viewport,
202
202
  is_page_target_callback: @is_page_target_callback,
@@ -1,4 +1,5 @@
1
1
  class Puppeteer::ChromeTargetManager
2
+ include Puppeteer::DebugPrint
2
3
  include Puppeteer::EventCallbackable
3
4
 
4
5
  def initialize(connection:, target_factory:, target_filter_callback:)
@@ -33,20 +34,34 @@ class Puppeteer::ChromeTargetManager
33
34
  )
34
35
 
35
36
  setup_attachment_listeners(@connection)
36
- @connection.async_send_message('Target.setDiscoverTargets', discover: true)
37
+ @connection.async_send_message('Target.setDiscoverTargets', {
38
+ discover: true,
39
+ filter: [
40
+ { type: 'tab', exclude: true },
41
+ {},
42
+ ],
43
+ }).then do
44
+ store_existing_targets_for_init
45
+ end.rescue do |err|
46
+ debug_puts(err)
47
+ end
37
48
  end
38
49
 
39
- def init
50
+ private def store_existing_targets_for_init
40
51
  @discovered_targets_by_target_id.each do |target_id, target_info|
41
- if @target_filter_callback.call(target_info)
52
+ if @target_filter_callback.call(target_info) && target_info.type != 'browser'
42
53
  @target_ids_for_init << target_id
43
54
  end
44
55
  end
56
+ end
57
+
58
+ def init
45
59
  @connection.send_message('Target.setAutoAttach', {
46
60
  waitForDebuggerOnStart: true,
47
61
  flatten: true,
48
62
  autoAttach: true,
49
63
  })
64
+ finish_initialization_if_ready
50
65
  @initialize_promise.value!
51
66
  end
52
67
 
@@ -114,7 +129,7 @@ class Puppeteer::ChromeTargetManager
114
129
  # Special case (https://crbug.com/1338156): currently, shared_workers
115
130
  # don't get auto-attached. This should be removed once the auto-attach
116
131
  # works.
117
- @connection.create_session(target_info)
132
+ @connection.create_session(target_info, auto_attach_emulated: true)
118
133
  end
119
134
  end
120
135
 
@@ -170,6 +185,8 @@ class Puppeteer::ChromeTargetManager
170
185
  end
171
186
  }
172
187
 
188
+ return unless @connection.auto_attached?(target_info.target_id)
189
+
173
190
  # Special case for service workers: being attached to service workers will
174
191
  # prevent them from ever being destroyed. Therefore, we silently detach
175
192
  # from service workers unless the connection was manually created via
@@ -197,6 +214,8 @@ class Puppeteer::ChromeTargetManager
197
214
  return
198
215
  end
199
216
 
217
+ is_existing_target = @attached_targets_by_target_id.has_key?(target_info.target_id)
218
+
200
219
  target = @attached_targets_by_target_id[target_info.target_id] || @target_factory.call(target_info, session)
201
220
  setup_attachment_listeners(session)
202
221
 
@@ -218,11 +237,10 @@ class Puppeteer::ChromeTargetManager
218
237
  end
219
238
 
220
239
  @target_ids_for_init.delete(target.target_id)
221
- future { emit_event(TargetManagerEmittedEvents::TargetAvailable, target) }
222
-
223
- if @target_ids_for_init.empty?
224
- @initialize_promise.fulfill(nil) unless @initialize_promise.resolved?
240
+ unless is_existing_target
241
+ future { emit_event(TargetManagerEmittedEvents::TargetAvailable, target) }
225
242
  end
243
+ finish_initialization_if_ready
226
244
 
227
245
  future do
228
246
  # TODO: the browser might be shutting down here. What do we do with the error?
@@ -239,8 +257,8 @@ class Puppeteer::ChromeTargetManager
239
257
  end
240
258
  end
241
259
 
242
- private def finish_initialization_if_ready(target_id)
243
- @target_ids_for_init.delete(target_id)
260
+ private def finish_initialization_if_ready(target_id = nil)
261
+ @target_ids_for_init.delete(target_id) if target_id
244
262
  if @target_ids_for_init.empty?
245
263
  @initialize_promise.fulfill(nil) unless @initialize_promise.resolved?
246
264
  end
@@ -319,15 +319,18 @@ class Puppeteer::Connection
319
319
  end
320
320
 
321
321
  def auto_attached?(target_id)
322
- @manually_attached.include?(target_id)
322
+ !@manually_attached.include?(target_id)
323
323
  end
324
324
 
325
325
  # @param {Protocol.Target.TargetInfo} targetInfo
326
326
  # @return [CDPSession]
327
- def create_session(target_info)
328
- @manually_attached << target_info.target_id
327
+ def create_session(target_info, auto_attach_emulated: false)
328
+ unless auto_attach_emulated
329
+ @manually_attached << target_info.target_id
330
+ end
329
331
  result = send_message('Target.attachToTarget', targetId: target_info.target_id, flatten: true)
330
332
  session_id = result['sessionId']
333
+ @manually_attached.delete(target_info.target_id)
331
334
  @sessions[session_id]
332
335
  end
333
336
  end
@@ -108,7 +108,7 @@ class Puppeteer::Coverage
108
108
 
109
109
  # Filter out empty ranges.
110
110
  results.select do |range|
111
- range[:end] - range[:start] > 1
111
+ range[:end] - range[:start] > 0
112
112
  end
113
113
  end
114
114
  end
@@ -21,12 +21,39 @@ class Puppeteer::CustomQueryHandler
21
21
  nil
22
22
  end
23
23
 
24
- def wait_for(dom_world, selector, visible: nil, hidden: nil, timeout: nil, root: nil)
24
+ def wait_for(element_or_frame, selector, visible: nil, hidden: nil, timeout: nil)
25
+ case element_or_frame
26
+ when Puppeteer::Frame
27
+ frame = element_or_frame
28
+ element = nil
29
+ when Puppeteer::ElementHandle
30
+ frame = element_or_frame.frame
31
+ element = frame.puppeteer_world.adopt_handle(element_or_frame)
32
+ else
33
+ raise ArgumentError.new("element_or_frame must be a Frame or ElementHandle. #{element_or_frame.inspect}")
34
+ end
35
+
25
36
  unless @query_one
26
37
  raise NotImplementedError.new("#{self.class}##{__method__} is not implemented.")
27
38
  end
28
39
 
29
- dom_world.send(:wait_for_selector_in_page, @query_one, selector, visible: visible, hidden: hidden, timeout: timeout, root: root)
40
+ result = frame.puppeteer_world.send(:wait_for_selector_in_page,
41
+ @query_one,
42
+ element,
43
+ selector,
44
+ visible: visible,
45
+ hidden: hidden,
46
+ timeout: timeout,
47
+ )
48
+
49
+ element&.dispose
50
+
51
+ if result.is_a?(Puppeteer::ElementHandle)
52
+ result.frame.main_world.transfer_handle(result)
53
+ else
54
+ result&.dispose
55
+ nil
56
+ end
30
57
  end
31
58
 
32
59
  def query_all(element, selector)
@@ -12,16 +12,16 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
12
12
  # @param client [Puppeteer::CDPSession]
13
13
  # @param remote_object [Puppeteer::RemoteObject]
14
14
  # @param frame [Puppeteer::Frame]
15
- # @param page [Puppeteer::Page]
16
- # @param frame_manager [Puppeteer::FrameManager]
17
- def initialize(context:, client:, remote_object:, frame:, page:, frame_manager:)
15
+ def initialize(context:, client:, remote_object:, frame:)
18
16
  super(context: context, client: client, remote_object: remote_object)
19
17
  @frame = frame
20
- @page = page
21
- @frame_manager = frame_manager
18
+ @page = frame.page
19
+ @frame_manager = frame.frame_manager
22
20
  @disposed = false
23
21
  end
24
22
 
23
+ attr_reader :page, :frame, :frame_manager
24
+
25
25
  def inspect
26
26
  values = %i[context remote_object page disposed].map do |sym|
27
27
  value = instance_variable_get(:"@#{sym}")
@@ -60,18 +60,7 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
60
60
  # (30 seconds). Pass `0` to disable timeout. The default value can be changed
61
61
  # by using the {@link Page.setDefaultTimeout} method.
62
62
  def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil)
63
- frame = @context.frame
64
-
65
- secondary_world = frame.secondary_world
66
- adopted_root = secondary_world.execution_context.adopt_element_handle(self)
67
- handle = secondary_world.wait_for_selector(selector, visible: visible, hidden: hidden, timeout: timeout, root: adopted_root)
68
- adopted_root.dispose
69
- return nil unless handle
70
-
71
- main_world = frame.main_world
72
- result = main_world.execution_context.adopt_element_handle(handle)
73
- handle.dispose
74
- result
63
+ query_handler_manager.detect_query_handler(selector).wait_for(self, visible: visible, hidden: hidden, timeout: timeout)
75
64
  end
76
65
 
77
66
  define_async_method :async_wait_for_selector
@@ -125,28 +114,14 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
125
114
  # Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default
126
115
  # value can be changed by using the {@link Page.setDefaultTimeout} method.
127
116
  def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil)
128
- frame = @context.frame
129
-
130
- secondary_world = frame.secondary_world
131
- adopted_root = secondary_world.execution_context.adopt_element_handle(self)
132
117
  param_xpath =
133
118
  if xpath.start_with?('//')
134
119
  ".#{xpath}"
135
120
  else
136
121
  xpath
137
122
  end
138
- unless param_xpath.start_with?('.//')
139
- adopted_root.dispose
140
- raise ArgumentError.new("Unsupported xpath expression: #{xpath}")
141
- end
142
- handle = secondary_world.wait_for_xpath(param_xpath, visible: visible, hidden: hidden, timeout: timeout, root: adopted_root)
143
- adopted_root.dispose
144
- return nil unless handle
145
123
 
146
- main_world = frame.main_world
147
- result = main_world.execution_context.adopt_element_handle(handle)
148
- handle.dispose
149
- result
124
+ wait_for_selector("xpath/#{param_xpath}", visible: visible, hidden: hidden, timeout: timeout)
150
125
  end
151
126
 
152
127
  define_async_method :async_wait_for_xpath
@@ -623,21 +598,14 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
623
598
  # @param expression [String]
624
599
  # @return [Array<ElementHandle>]
625
600
  def Sx(expression)
626
- fn = <<~JAVASCRIPT
627
- (element, expression) => {
628
- const document = element.ownerDocument || element;
629
- const iterator = document.evaluate(expression, element, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
630
- const array = [];
631
- let item;
632
- while ((item = iterator.iterateNext()))
633
- array.push(item);
634
- return array;
635
- }
636
- JAVASCRIPT
637
- handles = evaluate_handle(fn, expression)
638
- properties = handles.properties
639
- handles.dispose
640
- properties.values.map(&:as_element).compact
601
+ param_xpath =
602
+ if expression.start_with?('//')
603
+ ".#{expression}"
604
+ else
605
+ expression
606
+ end
607
+
608
+ query_selector_all("xpath/#{param_xpath}")
641
609
  end
642
610
 
643
611
  define_async_method :async_Sx
@@ -674,6 +642,6 @@ class Puppeteer::ElementHandle < Puppeteer::JSHandle
674
642
  # used in AriaQueryHandler
675
643
  def query_ax_tree(accessible_name: nil, role: nil)
676
644
  @remote_object.query_ax_tree(@client,
677
- accessible_name: accessible_name, role: role)
645
+ accessible_name: accessible_name, role: role)
678
646
  end
679
647
  end
@@ -7,7 +7,7 @@ class Puppeteer::ExecutionContext
7
7
 
8
8
  # @param client [Puppeteer::CDPSession]
9
9
  # @param context_payload [Hash]
10
- # @param world [Puppeteer::DOMWorld?]
10
+ # @param world [Puppeteer::IsolaatedWorld?]
11
11
  def initialize(client, context_payload, world)
12
12
  @client = client
13
13
  @world = world
@@ -17,23 +17,16 @@ class Puppeteer::ExecutionContext
17
17
 
18
18
  attr_reader :client, :world
19
19
 
20
- # only used in DOMWorld
20
+ # only used in IsolaatedWorld
21
21
  private def _context_id
22
22
  @context_id
23
23
  end
24
24
 
25
- # only used in DOMWorld::BindingFunction#add_binding_to_context
25
+ # only used in IsolaatedWorld::BindingFunction#add_binding_to_context
26
26
  private def _context_name
27
27
  @context_name
28
28
  end
29
29
 
30
- # @return [Puppeteer::Frame]
31
- def frame
32
- if_present(@world) do |world|
33
- world.frame
34
- end
35
- end
36
-
37
30
  # @param page_function [String]
38
31
  # @return [Object]
39
32
  def evaluate(page_function, *args)
@@ -208,54 +201,4 @@ class Puppeteer::ExecutionContext
208
201
  context_id: @context_id,
209
202
  )
210
203
  end
211
-
212
- # /**
213
- # * @param {!JSHandle} prototypeHandle
214
- # * @return {!Promise<!JSHandle>}
215
- # */
216
- # async queryObjects(prototypeHandle) {
217
- # assert(!prototypeHandle._disposed, 'Prototype JSHandle is disposed!');
218
- # assert(prototypeHandle._remoteObject.objectId, 'Prototype JSHandle must not be referencing primitive value');
219
- # const response = await this._client.send('Runtime.queryObjects', {
220
- # prototypeObjectId: prototypeHandle._remoteObject.objectId
221
- # });
222
- # return createJSHandle(this, response.objects);
223
- # }
224
-
225
- # @param backend_node_id [Integer]
226
- # @return [Puppeteer::ElementHandle]
227
- def adopt_backend_node_id(backend_node_id)
228
- response = @client.send_message('DOM.resolveNode',
229
- backendNodeId: backend_node_id,
230
- executionContextId: @context_id,
231
- )
232
- Puppeteer::JSHandle.create(
233
- context: self,
234
- remote_object: Puppeteer::RemoteObject.new(response["object"]),
235
- )
236
- end
237
- private define_async_method :async_adopt_backend_node_id
238
-
239
- # @param element_handle [Puppeteer::ElementHandle]
240
- # @return [Puppeteer::ElementHandle]
241
- def adopt_element_handle(element_handle)
242
- if element_handle.execution_context == self
243
- raise ArgumentError.new('Cannot adopt handle that already belongs to this execution context')
244
- end
245
-
246
- unless @world
247
- raise 'Cannot adopt handle without DOMWorld'
248
- end
249
-
250
- node_info = element_handle.remote_object.node_info(@client)
251
- response = @client.send_message('DOM.resolveNode',
252
- backendNodeId: node_info["node"]["backendNodeId"],
253
- executionContextId: @context_id,
254
- )
255
-
256
- Puppeteer::JSHandle.create(
257
- context: self,
258
- remote_object: Puppeteer::RemoteObject.new(response["object"]),
259
- )
260
- end
261
204
  end
@@ -37,8 +37,8 @@ class Puppeteer::Frame
37
37
  # @param client [Puppeteer::CDPSession]
38
38
  private def update_client(client)
39
39
  @client = client
40
- @main_world = Puppeteer::DOMWorld.new(@client, @frame_manager, self, @frame_manager.timeout_settings)
41
- @secondary_world = Puppeteer::DOMWorld.new(@client, @frame_manager, self, @frame_manager.timeout_settings)
40
+ @main_world = Puppeteer::IsolaatedWorld.new(@client, @frame_manager, self, @frame_manager.timeout_settings)
41
+ @puppeteer_world = Puppeteer::IsolaatedWorld.new(@client, @frame_manager, self, @frame_manager.timeout_settings)
42
42
  end
43
43
 
44
44
  def page
@@ -49,7 +49,7 @@ class Puppeteer::Frame
49
49
  @client != @frame_manager.client
50
50
  end
51
51
 
52
- attr_accessor :frame_manager, :id, :loader_id, :lifecycle_events, :main_world, :secondary_world
52
+ attr_accessor :frame_manager, :id, :loader_id, :lifecycle_events, :main_world, :puppeteer_world
53
53
 
54
54
  def has_started_loading?
55
55
  @has_started_loading
@@ -106,7 +106,14 @@ class Puppeteer::Frame
106
106
  # @param {string} expression
107
107
  # @return {!Promise<!Array<!Puppeteer.ElementHandle>>}
108
108
  def Sx(expression)
109
- @main_world.Sx(expression)
109
+ param_xpath =
110
+ if expression.start_with?('//')
111
+ ".#{expression}"
112
+ else
113
+ expression
114
+ end
115
+
116
+ query_selector_all("xpath/#{param_xpath}")
110
117
  end
111
118
 
112
119
  define_async_method :async_Sx
@@ -147,14 +154,14 @@ class Puppeteer::Frame
147
154
 
148
155
  # @return [String]
149
156
  def content
150
- @secondary_world.content
157
+ @puppeteer_world.content
151
158
  end
152
159
 
153
160
  # @param html [String]
154
161
  # @param timeout [Integer]
155
162
  # @param wait_until [String|Array<String>]
156
163
  def set_content(html, timeout: nil, wait_until: nil)
157
- @secondary_world.set_content(html, timeout: timeout, wait_until: wait_until)
164
+ @puppeteer_world.set_content(html, timeout: timeout, wait_until: wait_until)
158
165
  end
159
166
 
160
167
  # @return [String]
@@ -205,35 +212,35 @@ class Puppeteer::Frame
205
212
  # @param button [String] "left"|"right"|"middle"
206
213
  # @param click_count [Number]
207
214
  def click(selector, delay: nil, button: nil, click_count: nil)
208
- @secondary_world.click(selector, delay: delay, button: button, click_count: click_count)
215
+ @puppeteer_world.click(selector, delay: delay, button: button, click_count: click_count)
209
216
  end
210
217
 
211
218
  define_async_method :async_click
212
219
 
213
220
  # @param {string} selector
214
221
  def focus(selector)
215
- @secondary_world.focus(selector)
222
+ @puppeteer_world.focus(selector)
216
223
  end
217
224
 
218
225
  define_async_method :async_focus
219
226
 
220
227
  # @param {string} selector
221
228
  def hover(selector)
222
- @secondary_world.hover(selector)
229
+ @puppeteer_world.hover(selector)
223
230
  end
224
231
 
225
232
  # @param {string} selector
226
233
  # @param {!Array<string>} values
227
234
  # @return {!Promise<!Array<string>>}
228
235
  def select(selector, *values)
229
- @secondary_world.select(selector, *values)
236
+ @puppeteer_world.select(selector, *values)
230
237
  end
231
238
 
232
239
  define_async_method :async_select
233
240
 
234
241
  # @param {string} selector
235
242
  def tap(selector)
236
- @secondary_world.tap(selector)
243
+ @puppeteer_world.tap(selector)
237
244
  end
238
245
 
239
246
  define_async_method :async_tap
@@ -252,14 +259,8 @@ class Puppeteer::Frame
252
259
  # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
253
260
  # @param timeout [Integer]
254
261
  def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil)
255
- handle = @secondary_world.wait_for_selector(selector, visible: visible, hidden: hidden, timeout: timeout)
256
- if !handle
257
- return nil
258
- end
259
- main_execution_context = @main_world.execution_context
260
- result = main_execution_context.adopt_element_handle(handle)
261
- handle.dispose
262
- result
262
+ query_handler_manager = Puppeteer::QueryHandlerManager.instance
263
+ query_handler_manager.detect_query_handler(selector).wait_for(self, visible: visible, hidden: hidden, timeout: timeout)
263
264
  end
264
265
 
265
266
  define_async_method :async_wait_for_selector
@@ -274,14 +275,14 @@ class Puppeteer::Frame
274
275
  # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
275
276
  # @param timeout [Integer]
276
277
  def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil)
277
- handle = @secondary_world.wait_for_xpath(xpath, visible: visible, hidden: hidden, timeout: timeout)
278
- if !handle
279
- return nil
280
- end
281
- main_execution_context = @main_world.execution_context
282
- result = main_execution_context.adopt_element_handle(handle)
283
- handle.dispose
284
- result
278
+ param_xpath =
279
+ if xpath.start_with?('//')
280
+ ".#{xpath}"
281
+ else
282
+ xpath
283
+ end
284
+
285
+ wait_for_selector("xpath/#{param_xpath}", visible: visible, hidden: hidden, timeout: timeout)
285
286
  end
286
287
 
287
288
  define_async_method :async_wait_for_xpath
@@ -299,7 +300,7 @@ class Puppeteer::Frame
299
300
 
300
301
  # @return [String]
301
302
  def title
302
- @secondary_world.title
303
+ @puppeteer_world.title
303
304
  end
304
305
 
305
306
  # @param frame_payload [Hash]
@@ -337,7 +338,7 @@ class Puppeteer::Frame
337
338
  def detach
338
339
  @detached = true
339
340
  @main_world.detach
340
- @secondary_world.detach
341
+ @puppeteer_world.detach
341
342
  if @parent_frame
342
343
  @parent_frame._child_frames.delete(self)
343
344
  end
@@ -144,13 +144,13 @@ class Puppeteer::FrameManager
144
144
  watcher.same_document_navigation_promise
145
145
  end,
146
146
  )
147
+
148
+ watcher.navigation_response
147
149
  rescue Puppeteer::TimeoutError => err
148
150
  raise NavigationError.new(err)
149
151
  ensure
150
152
  watcher.dispose
151
153
  end
152
-
153
- watcher.navigation_response
154
154
  end
155
155
 
156
156
  # @param timeout [number|nil]
@@ -168,13 +168,13 @@ class Puppeteer::FrameManager
168
168
  watcher.same_document_navigation_promise,
169
169
  watcher.new_document_navigation_promise,
170
170
  )
171
+
172
+ watcher.navigation_response
171
173
  rescue Puppeteer::TimeoutError => err
172
174
  raise NavigationError.new(err)
173
175
  ensure
174
176
  watcher.dispose
175
177
  end
176
-
177
- watcher.navigation_response
178
178
  end
179
179
 
180
180
  # @param event [Hash]
@@ -413,11 +413,11 @@ class Puppeteer::FrameManager
413
413
 
414
414
  if context_payload.dig('auxData', 'isDefault')
415
415
  world = frame.main_world
416
- elsif context_payload['name'] == UTILITY_WORLD_NAME && !frame.secondary_world.has_context?
416
+ elsif context_payload['name'] == UTILITY_WORLD_NAME && !frame.puppeteer_world.has_context?
417
417
  # In case of multiple sessions to the same target, there's a race between
418
418
  # connections so we might end up creating multiple isolated worlds.
419
419
  # We can use either.
420
- world = frame.secondary_world
420
+ world = frame.puppeteer_world
421
421
  end
422
422
  end
423
423
 
@@ -1,7 +1,7 @@
1
1
  require 'thread'
2
2
 
3
- # https://github.com/puppeteer/puppeteer/blob/master/src/DOMWorld.js
4
- class Puppeteer::DOMWorld
3
+ # https://github.com/puppeteer/puppeteer/blob/master/src/IsolaatedWorld.js
4
+ class Puppeteer::IsolaatedWorld
5
5
  using Puppeteer::DefineAsyncMethod
6
6
 
7
7
  class BindingFunction
@@ -152,7 +152,7 @@ class Puppeteer::DOMWorld
152
152
  raise 'Bug of puppeteer-ruby...'
153
153
  end
154
154
 
155
- private def document
155
+ def document
156
156
  @document ||= evaluate_document.as_element
157
157
  end
158
158
 
@@ -416,16 +416,6 @@ class Puppeteer::DOMWorld
416
416
  handle.dispose
417
417
  end
418
418
 
419
- # @param selector [String]
420
- # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
421
- # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
422
- # @param timeout [Integer]
423
- def wait_for_selector(selector, visible: nil, hidden: nil, timeout: nil, root: nil)
424
- # call wait_for_selector_in_page with custom query selector.
425
- query_selector_manager = Puppeteer::QueryHandlerManager.instance
426
- query_selector_manager.detect_query_handler(selector).wait_for(self, visible: visible, hidden: hidden, timeout: timeout, root: root)
427
- end
428
-
429
419
  private def binding_identifier(name, context)
430
420
  "#{name}_#{context.send(:_context_id)}"
431
421
  end
@@ -497,7 +487,7 @@ class Puppeteer::DOMWorld
497
487
  # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
498
488
  # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
499
489
  # @param timeout [Integer]
500
- private def wait_for_selector_in_page(query_one, selector, visible: nil, hidden: nil, timeout: nil, root: nil, binding_function: nil)
490
+ private def wait_for_selector_in_page(query_one, root, selector, visible: nil, hidden: nil, timeout: nil, binding_function: nil)
501
491
  option_wait_for_visible = visible || false
502
492
  option_wait_for_hidden = hidden || false
503
493
  option_timeout = timeout || @timeout_settings.timeout
@@ -531,55 +521,7 @@ class Puppeteer::DOMWorld
531
521
  root: option_root,
532
522
  binding_function: binding_function,
533
523
  )
534
- handle = wait_task.await_promise
535
- unless handle.as_element
536
- handle.dispose
537
- return nil
538
- end
539
- handle.as_element
540
- end
541
-
542
- # @param xpath [String]
543
- # @param visible [Boolean] Wait for element visible (not 'display: none' nor 'visibility: hidden') on true. default to false.
544
- # @param hidden [Boolean] Wait for element invisible ('display: none' nor 'visibility: hidden') on true. default to false.
545
- # @param timeout [Integer]
546
- def wait_for_xpath(xpath, visible: nil, hidden: nil, timeout: nil, root: nil)
547
- option_wait_for_visible = visible || false
548
- option_wait_for_hidden = hidden || false
549
- option_timeout = timeout || @timeout_settings.timeout
550
- option_root = root
551
-
552
- polling =
553
- if option_wait_for_visible || option_wait_for_hidden
554
- 'raf'
555
- else
556
- 'mutation'
557
- end
558
- title = "XPath #{xpath}#{option_wait_for_hidden ? 'to be hidden' : ''}"
559
-
560
- xpath_predicate = make_predicate_string(
561
- predicate_arg_def: '(root, selector, waitForVisible, waitForHidden)',
562
- predicate_body: <<~JAVASCRIPT
563
- const node = document.evaluate(selector, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
564
- return checkWaitForOptions(node, waitForVisible, waitForHidden);
565
- JAVASCRIPT
566
- )
567
-
568
- wait_task = Puppeteer::WaitTask.new(
569
- dom_world: self,
570
- predicate_body: xpath_predicate,
571
- title: title,
572
- polling: polling,
573
- timeout: option_timeout,
574
- args: [xpath, option_wait_for_visible, option_wait_for_hidden],
575
- root: option_root,
576
- )
577
- handle = wait_task.await_promise
578
- unless handle.as_element
579
- handle.dispose
580
- return nil
581
- end
582
- handle.as_element
524
+ wait_task.await_promise
583
525
  end
584
526
 
585
527
  # @param page_function [String]
@@ -644,4 +586,35 @@ class Puppeteer::DOMWorld
644
586
  }
645
587
  JAVASCRIPT
646
588
  end
589
+
590
+ # @param backend_node_id [Integer]
591
+ # @return [Puppeteer::ElementHandle]
592
+ def adopt_backend_node(backend_node_id)
593
+ response = @client.send_message('DOM.resolveNode',
594
+ backendNodeId: backend_node_id,
595
+ executionContextId: execution_context.send(:_context_id),
596
+ )
597
+ Puppeteer::JSHandle.create(
598
+ context: execution_context,
599
+ remote_object: Puppeteer::RemoteObject.new(response["object"]),
600
+ )
601
+ end
602
+ private define_async_method :async_adopt_backend_node
603
+
604
+ # @param element_handle [Puppeteer::ElementHandle]
605
+ # @return [Puppeteer::ElementHandle]
606
+ def adopt_handle(element_handle)
607
+ if element_handle.execution_context == execution_context
608
+ raise ArgumentError.new('Cannot adopt handle that already belongs to this execution context')
609
+ end
610
+
611
+ node_info = element_handle.remote_object.node_info(@client)
612
+ adopt_backend_node(node_info["node"]["backendNodeId"])
613
+ end
614
+
615
+ def transfer_handle(element_handle)
616
+ result = adopt_handle(element_handle)
617
+ element_handle.dispose
618
+ result
619
+ end
647
620
  end
@@ -5,16 +5,12 @@ class Puppeteer::JSHandle
5
5
  # @param context [Puppeteer::ExecutionContext]
6
6
  # @param remote_object [Puppeteer::RemoteObject]
7
7
  def self.create(context:, remote_object:)
8
- frame = context.frame
9
- if remote_object.sub_type == 'node' && frame
10
- frame_manager = frame.frame_manager
8
+ if remote_object.sub_type == 'node' && context.world
11
9
  Puppeteer::ElementHandle.new(
12
10
  context: context,
13
11
  client: context.client,
14
12
  remote_object: remote_object,
15
- frame: frame,
16
- page: frame_manager.page,
17
- frame_manager: frame_manager,
13
+ frame: context.world.frame,
18
14
  )
19
15
  else
20
16
  Puppeteer::JSHandle.new(
@@ -81,7 +81,10 @@ class Puppeteer::LifecycleWatcher
81
81
  @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameSwapped, &method(:handle_frame_swapped)),
82
82
  @frame_manager.add_event_listener(FrameManagerEmittedEvents::FrameDetached, &method(:handle_frame_detached)),
83
83
  ]
84
- @listener_ids['network_manager'] = @frame_manager.network_manager.add_event_listener(NetworkManagerEmittedEvents::Request, &method(:handle_request))
84
+ @listener_ids['network_manager'] = [
85
+ @frame_manager.network_manager.add_event_listener(NetworkManagerEmittedEvents::Request, &method(:handle_request)),
86
+ @frame_manager.network_manager.add_event_listener(NetworkManagerEmittedEvents::Response, &method(:handle_response)),
87
+ ]
85
88
 
86
89
  @same_document_navigation_promise = resolvable_future
87
90
  @lifecycle_promise = resolvable_future
@@ -94,6 +97,21 @@ class Puppeteer::LifecycleWatcher
94
97
  def handle_request(request)
95
98
  return if request.frame != @frame || !request.navigation_request?
96
99
  @navigation_request = request
100
+ # Resolve previous navigation response in case there are multiple
101
+ # navigation requests reported by the backend. This generally should not
102
+ # happen by it looks like it's possible.
103
+ @navigation_response_received&.fulfill(nil)
104
+ @navigation_response_received = resolvable_future
105
+ if request.response && !@navigation_response_received.resolved?
106
+ @navigation_response_received.fulfill(nil)
107
+ end
108
+ end
109
+
110
+ # @param [Puppeteer::HTTPResponse] response
111
+ def handle_response(response)
112
+ return if @navigation_request&.internal&.request_id != response.request.internal.request_id
113
+
114
+ @navigation_response_received.fulfill(nil) unless @navigation_response_received.resolved?
97
115
  end
98
116
 
99
117
  # @param frame [Puppeteer::Frame]
@@ -107,6 +125,8 @@ class Puppeteer::LifecycleWatcher
107
125
 
108
126
  # @return [Puppeteer::HTTPResponse]
109
127
  def navigation_response
128
+ # Continue with a possibly null response.
129
+ @navigation_response_received.value! rescue nil
110
130
  if_present(@navigation_request) do |request|
111
131
  request.response
112
132
  end
@@ -175,8 +195,8 @@ class Puppeteer::LifecycleWatcher
175
195
  if_present(@listener_ids['frame_manager']) do |ids|
176
196
  @frame_manager.remove_event_listener(*ids)
177
197
  end
178
- if_present(@listener_ids['network_manager']) do |id|
179
- @frame_manager.network_manager.remove_event_listener(id)
198
+ if_present(@listener_ids['network_manager']) do |ids|
199
+ @frame_manager.network_manager.remove_event_listener(*ids)
180
200
  end
181
201
  end
182
202
  end
@@ -191,8 +191,7 @@ class Puppeteer::Page
191
191
  return if @file_chooser_interceptors.empty?
192
192
 
193
193
  frame = @frame_manager.frame(event['frameId'])
194
- context = frame.execution_context
195
- element = context.adopt_backend_node_id(event['backendNodeId'])
194
+ element = frame.main_world.adopt_backend_node(event['backendNodeId'])
196
195
  interceptors = @file_chooser_interceptors.to_a
197
196
  @file_chooser_interceptors.clear
198
197
  file_chooser = Puppeteer::FileChooser.new(element, event)
@@ -1070,7 +1069,8 @@ class Puppeteer::Page
1070
1069
  clip = if_present(screenshot_options.clip) do |rect|
1071
1070
  x = rect[:x].round
1072
1071
  y = rect[:y].round
1073
- { x: x, y: y, width: rect[:width] + rect[:x] - x, height: rect[:height] + rect[:y] - y, scale: 1 }
1072
+ scale = rect[:scale] || 1
1073
+ { x: x, y: y, width: rect[:width] + rect[:x] - x, height: rect[:height] + rect[:y] - y, scale: scale }
1074
1074
  end
1075
1075
 
1076
1076
  if screenshot_options.full_page?
@@ -6,6 +6,7 @@ class Puppeteer::QueryHandlerManager
6
6
  def query_handlers
7
7
  @query_handlers ||= {
8
8
  aria: Puppeteer::AriaQueryHandler.new,
9
+ xpath: xpath_handler,
9
10
  }
10
11
  end
11
12
 
@@ -16,6 +17,40 @@ class Puppeteer::QueryHandlerManager
16
17
  )
17
18
  end
18
19
 
20
+ private def xpath_handler
21
+ @xpath_handler ||= Puppeteer::CustomQueryHandler.new(
22
+ query_one: <<~JAVASCRIPT,
23
+ (element, selector) => {
24
+ const doc = element.ownerDocument || document;
25
+ const result = doc.evaluate(
26
+ selector,
27
+ element,
28
+ null,
29
+ XPathResult.FIRST_ORDERED_NODE_TYPE
30
+ );
31
+ return result.singleNodeValue;
32
+ }
33
+ JAVASCRIPT
34
+ query_all: <<~JAVASCRIPT,
35
+ (element, selector) => {
36
+ const doc = element.ownerDocument || document;
37
+ const iterator = doc.evaluate(
38
+ selector,
39
+ element,
40
+ null,
41
+ XPathResult.ORDERED_NODE_ITERATOR_TYPE
42
+ );
43
+ const array = [];
44
+ let item;
45
+ while ((item = iterator.iterateNext())) {
46
+ array.push(item);
47
+ }
48
+ return array;
49
+ }
50
+ JAVASCRIPT
51
+ )
52
+ end
53
+
19
54
  class Result
20
55
  def initialize(query_handler:, selector:)
21
56
  @query_handler = query_handler
@@ -26,8 +61,8 @@ class Puppeteer::QueryHandlerManager
26
61
  @query_handler.query_one(element_handle, @selector)
27
62
  end
28
63
 
29
- def wait_for(dom_world, visible:, hidden:, timeout:, root:)
30
- @query_handler.wait_for(dom_world, @selector, visible: visible, hidden: hidden, timeout: timeout, root: root)
64
+ def wait_for(element_or_frame, visible:, hidden:, timeout:)
65
+ @query_handler.wait_for(element_or_frame, @selector, visible: visible, hidden: hidden, timeout: timeout)
31
66
  end
32
67
 
33
68
  def query_all(element_handle)
@@ -105,8 +105,8 @@ class Puppeteer::RemoteObject
105
105
  role: role,
106
106
  }.compact)
107
107
 
108
- result['nodes'].reject do |node|
109
- node['role']['value'] == 'text'
108
+ result['nodes'].select do |node|
109
+ node['role'] && node['role']['value'] != 'StaticText'
110
110
  end
111
111
  end
112
112
 
@@ -93,7 +93,7 @@ class Puppeteer::Target
93
93
  end
94
94
 
95
95
  def create_cdp_session
96
- @session_factory.call
96
+ @session_factory.call(false)
97
97
  end
98
98
 
99
99
  def target_manager
@@ -102,7 +102,7 @@ class Puppeteer::Target
102
102
 
103
103
  def page
104
104
  if @is_page_target_callback.call(@target_info) && @page.nil?
105
- client = @session || @session_factory.call
105
+ client = @session || @session_factory.call(true)
106
106
  @page = Puppeteer::Page.create(client, self, @ignore_https_errors, @default_viewport)
107
107
  end
108
108
  @page
@@ -1,3 +1,3 @@
1
1
  module Puppeteer
2
- VERSION = '0.43.0'
2
+ VERSION = '0.44.0'
3
3
  end
data/lib/puppeteer.rb CHANGED
@@ -32,7 +32,6 @@ require 'puppeteer/css_coverage'
32
32
  require 'puppeteer/custom_query_handler'
33
33
  require 'puppeteer/devices'
34
34
  require 'puppeteer/dialog'
35
- require 'puppeteer/dom_world'
36
35
  require 'puppeteer/emulation_manager'
37
36
  require 'puppeteer/exception_details'
38
37
  require 'puppeteer/executable_path_finder'
@@ -43,6 +42,7 @@ require 'puppeteer/frame'
43
42
  require 'puppeteer/frame_manager'
44
43
  require 'puppeteer/http_request'
45
44
  require 'puppeteer/http_response'
45
+ require 'puppeteer/isolated_world'
46
46
  require 'puppeteer/js_coverage'
47
47
  require 'puppeteer/js_handle'
48
48
  require 'puppeteer/keyboard'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: puppeteer-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.43.0
4
+ version: 0.44.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - YusukeIwaki
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-09-13 00:00:00.000000000 Z
11
+ date: 2022-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -272,7 +272,6 @@ files:
272
272
  - lib/puppeteer/device.rb
273
273
  - lib/puppeteer/devices.rb
274
274
  - lib/puppeteer/dialog.rb
275
- - lib/puppeteer/dom_world.rb
276
275
  - lib/puppeteer/element_handle.rb
277
276
  - lib/puppeteer/element_handle/bounding_box.rb
278
277
  - lib/puppeteer/element_handle/box_model.rb
@@ -294,6 +293,7 @@ files:
294
293
  - lib/puppeteer/http_request.rb
295
294
  - lib/puppeteer/http_response.rb
296
295
  - lib/puppeteer/if_present.rb
296
+ - lib/puppeteer/isolated_world.rb
297
297
  - lib/puppeteer/js_coverage.rb
298
298
  - lib/puppeteer/js_handle.rb
299
299
  - lib/puppeteer/keyboard.rb