puppeteer-ruby 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ class Puppeteer::ChromeTargetManager
2
+ include Puppeteer::EventCallbackable
3
+
4
+ def initialize(connection:, target_factory:, target_filter_callback:)
5
+ @discovered_targets_by_target_id = {}
6
+ @attached_targets_by_target_id = {}
7
+ @attached_targets_by_session_id = {}
8
+ @ignored_targets = Set.new
9
+ @target_ids_for_init = Set.new
10
+
11
+ @connection = connection
12
+ @target_filter_callback = target_filter_callback
13
+ @target_factory = target_factory
14
+ @target_interceptors = {}
15
+ @initialize_promise = resolvable_future
16
+
17
+ @connection_event_listeners = []
18
+ @connection_event_listeners << @connection.add_event_listener(
19
+ 'Target.targetCreated',
20
+ &method(:handle_target_created)
21
+ )
22
+ @connection_event_listeners << @connection.add_event_listener(
23
+ 'Target.targetDestroyed',
24
+ &method(:handle_target_destroyed)
25
+ )
26
+ @connection_event_listeners << @connection.add_event_listener(
27
+ 'Target.targetInfoChanged',
28
+ &method(:handle_target_info_changed)
29
+ )
30
+ @connection_event_listeners << @connection.add_event_listener(
31
+ 'sessiondetached',
32
+ &method(:handle_session_detached)
33
+ )
34
+
35
+ setup_attachment_listeners(@connection)
36
+ @connection.async_send_message('Target.setDiscoverTargets', discover: true)
37
+ end
38
+
39
+ def init
40
+ @discovered_targets_by_target_id.each do |target_id, target_info|
41
+ if @target_filter_callback.call(target_info)
42
+ @target_ids_for_init << target_id
43
+ end
44
+ end
45
+ @connection.send_message('Target.setAutoAttach', {
46
+ waitForDebuggerOnStart: true,
47
+ flatten: true,
48
+ autoAttach: true,
49
+ })
50
+ @initialize_promise.value!
51
+ end
52
+
53
+ def dispose
54
+ @connection.remove_event_listener(*@connection_event_listeners)
55
+ remove_attachment_listeners(@connection)
56
+ end
57
+
58
+ def available_targets
59
+ @attached_targets_by_target_id
60
+ end
61
+
62
+ def add_target_interceptor(client, interceptor)
63
+ interceptors = @target_interceptors[client] || []
64
+ interceptors << interceptor
65
+ @target_interceptors[client] = interceptors
66
+ end
67
+
68
+ def remove_target_interceptor(client, interceptor)
69
+ @target_interceptors[client]&.delete_if { |current| current == interceptor }
70
+ end
71
+
72
+ private def setup_attachment_listeners(session)
73
+ @attachment_listener_ids ||= {}
74
+ @attachment_listener_ids[session] ||= []
75
+
76
+ @attachment_listener_ids[session] << session.add_event_listener('Target.attachedToTarget') do |event|
77
+ handle_attached_to_target(session, event)
78
+ end
79
+
80
+ @attachment_listener_ids[session] << session.add_event_listener('Target.detachedFromTarget') do |event|
81
+ handle_detached_from_target(session, event)
82
+ end
83
+ end
84
+
85
+ private def remove_attachment_listeners(session)
86
+ return unless @attachment_listener_ids
87
+ listener_ids = @attachment_listener_ids.delete(session)
88
+ return if !listener_ids || listener_ids.empty?
89
+ session.remove_event_listener(*listener_ids)
90
+ end
91
+
92
+ private def handle_session_detached(session)
93
+ remove_attachment_listeners(session)
94
+ @target_interceptors.delete(session)
95
+ end
96
+
97
+ private def handle_target_created(event)
98
+ target_info = Puppeteer::Target::TargetInfo.new(event['targetInfo'])
99
+ @discovered_targets_by_target_id[target_info.target_id] = target_info
100
+
101
+ emit_event(TargetManagerEmittedEvents::TargetDiscovered, target_info)
102
+
103
+ # The connection is already attached to the browser target implicitly,
104
+ # therefore, no new CDPSession is created and we have special handling
105
+ # here.
106
+ if target_info.type == 'browser' && target_info.attached
107
+ return if @attached_targets_by_target_id[target_info.target_id]
108
+
109
+ target = @target_factory.call(target_info, nil)
110
+ @attached_targets_by_target_id[target_info.target_id] = target
111
+ end
112
+
113
+ if target_info.type == 'shared_worker'
114
+ # Special case (https://crbug.com/1338156): currently, shared_workers
115
+ # don't get auto-attached. This should be removed once the auto-attach
116
+ # works.
117
+ @connection.create_session(target_info)
118
+ end
119
+ end
120
+
121
+ private def handle_target_destroyed(event)
122
+ target_id = event['targetId']
123
+ target_info = @discovered_targets_by_target_id.delete(target_id)
124
+ finish_initialization_if_ready(target_id)
125
+
126
+ if target_info.type == 'service_worker' && @attached_targets_by_target_id.has_key?(target_id)
127
+ # Special case for service workers: report TargetGone event when
128
+ # the worker is destroyed.
129
+ target = @attached_targets_by_target_id.delete(target_id)
130
+ emit_event(TargetManagerEmittedEvents::TargetGone, target)
131
+ end
132
+ end
133
+
134
+ private def handle_target_info_changed(event)
135
+ target_info = Puppeteer::Target::TargetInfo.new(event['targetInfo'])
136
+ @discovered_targets_by_target_id[target_info.target_id] = target_info
137
+
138
+ if @ignored_targets.include?(target_info.target_id) || !@attached_targets_by_target_id.has_key?(target_info.target_id) || !target_info.attached
139
+ return
140
+ end
141
+ original_target = @attached_targets_by_target_id[target_info.target_id]
142
+ emit_event(TargetManagerEmittedEvents::TargetChanged, original_target, target_info)
143
+ end
144
+
145
+ class SessionNotCreatedError < StandardError ; end
146
+
147
+ private def handle_attached_to_target(parent_session, event)
148
+ target_info = Puppeteer::Target::TargetInfo.new(event['targetInfo'])
149
+ session_id = event['sessionId']
150
+ session = @connection.session(session_id)
151
+ unless session
152
+ raise SessionNotCreatedError.new("Session #{session_id} was not created.")
153
+ end
154
+
155
+ silent_detach = -> {
156
+ begin
157
+ session.send_message('Runtime.runIfWaitingForDebugger')
158
+ rescue => err
159
+ Logger.new($stderr).warn(err)
160
+ end
161
+
162
+ # We don't use `session.detach()` because that dispatches all commands on
163
+ # the connection instead of the parent session.
164
+ begin
165
+ parent_session.send_message('Target.detachFromTarget', {
166
+ sessionId: session.id,
167
+ })
168
+ rescue => err
169
+ Logger.new($stderr).warn(err)
170
+ end
171
+ }
172
+
173
+ # Special case for service workers: being attached to service workers will
174
+ # prevent them from ever being destroyed. Therefore, we silently detach
175
+ # from service workers unless the connection was manually created via
176
+ # `page.worker()`. To determine this, we use
177
+ # `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we
178
+ # should determine if a target is auto-attached or not with the help of
179
+ # CDP.
180
+ if target_info.type == 'service_worker' && @connection.auto_attached?(target_info.target_id)
181
+ finish_initialization_if_ready(target_info.target_id)
182
+ silent_detach.call
183
+ if parent_session.is_a?(Puppeteer::CDPSession)
184
+ target = @target_factory.call(target_info, parent_session)
185
+ @attached_targets_by_target_id[target_info.target_id] = target
186
+ emit_event(TargetManagerEmittedEvents::TargetAvailable, target)
187
+ end
188
+
189
+ return
190
+ end
191
+
192
+ unless @target_filter_callback.call(target_info)
193
+ @ignored_targets << target_info.target_id
194
+ finish_initialization_if_ready(target_info.target_id)
195
+ silent_detach.call
196
+
197
+ return
198
+ end
199
+
200
+ target = @attached_targets_by_target_id[target_info.target_id] || @target_factory.call(target_info, session)
201
+ setup_attachment_listeners(session)
202
+
203
+ @attached_targets_by_target_id[target_info.target_id] ||= target
204
+ @attached_targets_by_session_id[session.id] = target
205
+
206
+ @target_interceptors[parent_session]&.each do |interceptor|
207
+ if parent_session.is_a?(Puppeteer::Connection)
208
+ interceptor.call(target, nil)
209
+ else
210
+ # Sanity check: if parent session is not a connection, it should be
211
+ # present in #attachedTargetsBySessionId.
212
+ attached_target = @attached_targets_by_session_id[parent_session.id]
213
+ unless attached_target
214
+ raise "No target found for the parent session: #{parent_session.id}"
215
+ end
216
+ interceptor.call(target, attached_target)
217
+ end
218
+ end
219
+
220
+ @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?
225
+ end
226
+
227
+ future do
228
+ # TODO: the browser might be shutting down here. What do we do with the error?
229
+ await_all(
230
+ session.async_send_message('Target.setAutoAttach', {
231
+ waitForDebuggerOnStart: true,
232
+ flatten: true,
233
+ autoAttach: true,
234
+ }),
235
+ session.async_send_message('Runtime.runIfWaitingForDebugger'),
236
+ )
237
+ rescue => err
238
+ Logger.new($stderr).warn(err)
239
+ end
240
+ end
241
+
242
+ private def finish_initialization_if_ready(target_id)
243
+ @target_ids_for_init.delete(target_id)
244
+ if @target_ids_for_init.empty?
245
+ @initialize_promise.fulfill(nil) unless @initialize_promise.resolved?
246
+ end
247
+ end
248
+
249
+ private def handle_detached_from_target(parent_session, event)
250
+ session_id = event['sessionId']
251
+ target = @attached_targets_by_session_id.delete(session_id)
252
+ return unless target
253
+ @attached_targets_by_target_id.delete(target.target_id)
254
+ emit_event(TargetManagerEmittedEvents::TargetGone, target)
255
+ end
256
+ end
@@ -58,6 +58,7 @@ class Puppeteer::Connection
58
58
 
59
59
  @sessions = Concurrent::Hash.new
60
60
  @closed = false
61
+ @manually_attached = Set.new
61
62
  end
62
63
 
63
64
  # used only in Browser#connected?
@@ -243,12 +244,22 @@ class Puppeteer::Connection
243
244
  session_id = message['params']['sessionId']
244
245
  session = Puppeteer::CDPSession.new(self, message['params']['targetInfo']['type'], session_id)
245
246
  @sessions[session_id] = session
247
+ emit_event('sessionattached', session)
248
+ if message['sessionId']
249
+ parent_session = @sessions[message['sessionId']]
250
+ parent_session&.emit_event('sessionattached', session)
251
+ end
246
252
  when 'Target.detachedFromTarget'
247
253
  session_id = message['params']['sessionId']
248
254
  session = @sessions[session_id]
249
255
  if session
250
256
  session.handle_closed
251
257
  @sessions.delete(session_id)
258
+ emit_event('sessiondetached', session)
259
+ if message['sessionId']
260
+ parent_session = @sessions[message['sessionId']]
261
+ parent_session&.emit_event('sessiondetached', session)
262
+ end
252
263
  end
253
264
  end
254
265
 
@@ -307,9 +318,14 @@ class Puppeteer::Connection
307
318
  @transport.close
308
319
  end
309
320
 
321
+ def auto_attached?(target_id)
322
+ @manually_attached.include?(target_id)
323
+ end
324
+
310
325
  # @param {Protocol.Target.TargetInfo} targetInfo
311
326
  # @return [CDPSession]
312
327
  def create_session(target_info)
328
+ @manually_attached << target_info.target_id
313
329
  result = send_message('Target.attachToTarget', targetId: target_info.target_id, flatten: true)
314
330
  session_id = result['sessionId']
315
331
  @sessions[session_id]
@@ -182,3 +182,12 @@ module PageEmittedEvents ; end
182
182
  # {https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API WebWorker} is destroyed by the page.
183
183
  WorkerDestroyed: 'workerdestroyed',
184
184
  }.define_const_into(PageEmittedEvents)
185
+
186
+ module TargetManagerEmittedEvents ; end
187
+
188
+ {
189
+ TargetDiscovered: 'targetDiscovered',
190
+ TargetAvailable: 'targetAvailable',
191
+ TargetGone: 'targetGone',
192
+ TargetChanged: 'targetChanged',
193
+ }.define_const_into(TargetManagerEmittedEvents)
@@ -0,0 +1,158 @@
1
+ # FirefoxTargetManager implements target management using
2
+ # `Target.setDiscoverTargets` without using auto-attach. It, therefore, creates
3
+ # targets that lazily establish their CDP sessions.
4
+ #
5
+ # Although the approach is potentially flaky, there is no other way for Firefox
6
+ # because Firefox's CDP implementation does not support auto-attach.
7
+ #
8
+ # Firefox does not support targetInfoChanged and detachedFromTarget events:
9
+ # - https://bugzilla.mozilla.org/show_bug.cgi?id=1610855
10
+ # - https://bugzilla.mozilla.org/show_bug.cgi?id=1636979
11
+ class Puppeteer::FirefoxTargetManager
12
+ include Puppeteer::EventCallbackable
13
+
14
+ def initialize(connection:, target_factory:, target_filter_callback:)
15
+ @discovered_targets_by_target_id = {}
16
+ @available_targets_by_target_id = {}
17
+ @available_targets_by_session_id = {}
18
+ @ignored_targets = Set.new
19
+ @target_ids_for_init = Set.new
20
+
21
+ @connection = connection
22
+ @target_filter_callback = target_filter_callback
23
+ @target_factory = target_factory
24
+ @target_interceptors = {}
25
+ @initialize_promise = resolvable_future
26
+
27
+ @connection_event_listeners = []
28
+ @connection_event_listeners << @connection.add_event_listener(
29
+ 'Target.targetCreated',
30
+ &method(:handle_target_created)
31
+ )
32
+ @connection_event_listeners << @connection.add_event_listener(
33
+ 'Target.targetDestroyed',
34
+ &method(:handle_target_destroyed)
35
+ )
36
+ @connection_event_listeners << @connection.add_event_listener(
37
+ 'sessiondetached',
38
+ &method(:handle_session_detached)
39
+ )
40
+
41
+ setup_attachment_listeners(@connection)
42
+ end
43
+
44
+ def add_target_interceptor(client, interceptor)
45
+ interceptors = @target_interceptors[client] || []
46
+ interceptors << interceptor
47
+ @target_interceptors[client] = interceptors
48
+ end
49
+
50
+ def remove_target_interceptor(client, interceptor)
51
+ @target_interceptors[client]&.delete_if { |current| current == interceptor }
52
+ end
53
+
54
+ private def setup_attachment_listeners(session)
55
+ @attachment_listener_ids ||= {}
56
+ @attachment_listener_ids[session] ||= []
57
+
58
+ @attachment_listener_ids[session] << session.add_event_listener('Target.attachedToTarget') do |event|
59
+ handle_attached_to_target(session, event)
60
+ end
61
+ end
62
+
63
+ private def handle_session_detached(session)
64
+ remove_session_listeners(session)
65
+ @target_interceptors.delete(session)
66
+ end
67
+
68
+ private def remove_session_listeners(session)
69
+ return unless @attachment_listener_ids
70
+ listener_ids = @attachment_listener_ids.delete(session)
71
+ return if listener_ids.empty?
72
+ session.remove_event_listener(*listener_ids)
73
+ end
74
+
75
+ def available_targets
76
+ @available_targets_by_target_id
77
+ end
78
+
79
+ def dispose
80
+ @connection.remove_event_listener(*@connection_event_listeners)
81
+ remove_session_listeners(@connection)
82
+ end
83
+
84
+ def init
85
+ @connection.send_message('Target.setDiscoverTargets', discover: true)
86
+ @target_ids_for_init.merge(@discovered_targets_by_target_id.keys)
87
+ @initialize_promise.value!
88
+ end
89
+
90
+ private def handle_target_created(event)
91
+ target_info = Puppeteer::Target::TargetInfo.new(event['targetInfo'])
92
+ return if @discovered_targets_by_target_id[target_info.target_id]
93
+ @discovered_targets_by_target_id[target_info.target_id] = target_info
94
+
95
+ if target_info.type == 'browser' && target_info.attached
96
+ target = @target_factory.call(target_info, nil)
97
+ @available_targets_by_target_id[target_info.target_id] = target
98
+ finish_initialization_if_ready(target.target_id)
99
+ end
100
+
101
+ unless @target_filter_callback.call(target_info)
102
+ @ignored_targets << target_info.target_id
103
+ finish_initialization_if_ready(target_info.target_id)
104
+ return
105
+ end
106
+
107
+ target = @target_factory.call(target_info, nil)
108
+ @available_targets_by_target_id[target_info.target_id] = target
109
+ emit_event(TargetManagerEmittedEvents::TargetAvailable, target)
110
+ finish_initialization_if_ready(target.target_id)
111
+ end
112
+
113
+ private def handle_target_destroyed(event)
114
+ target_id = event['targetId']
115
+ target_info = @discovered_targets_by_target_id.delete(target_id)
116
+ finish_initialization_if_ready(target_id)
117
+
118
+ target = @available_targets_by_target_id.delete(target_id)
119
+ emit_event(TargetManagerEmittedEvents::TargetGone, target)
120
+ end
121
+
122
+ class SessionNotCreatedError < StandardError ; end
123
+
124
+ private def handle_attached_to_target(parent_session, event)
125
+ target_info = Puppeteer::Target::TargetInfo.new(event['targetInfo'])
126
+ session_id = event['sessionId']
127
+ session = @connection.session(session_id)
128
+ unless session
129
+ raise SessionNotCreatedError.new("Session #{session_id} was not created.")
130
+ end
131
+
132
+ target = @available_targets_by_target_id[target_info.target_id] or raise "Target #{target_info.target_id} is missing"
133
+ setup_attachment_listeners(session)
134
+
135
+ @available_targets_by_session_id[session_id] = target
136
+
137
+ @target_interceptors[parent_session]&.each do |hook|
138
+ if parent_session.is_a?(Puppeteer::Connection)
139
+ hook.call(target, nil)
140
+ else
141
+ # Sanity check: if parent session is not a connection, it should be
142
+ # present in #attachedTargetsBySessionId.
143
+ available_target = @available_targets_by_session_id[parent_session.id]
144
+ unless available_target
145
+ raise "No target found for the parent session: #{parent_session.id}"
146
+ end
147
+ hook.call(target, available_target)
148
+ end
149
+ end
150
+ end
151
+
152
+ private def finish_initialization_if_ready(target_id)
153
+ @target_ids_for_init.delete(target_id)
154
+ if @target_ids_for_init.empty?
155
+ @initialize_promise.fulfill(nil) unless @initialize_promise.resolved?
156
+ end
157
+ end
158
+ end
@@ -27,6 +27,13 @@ class Puppeteer::FrameManager
27
27
  # @type {!Set<string>}
28
28
  @isolated_worlds = Set.new
29
29
 
30
+ # Keeps track of OOPIF targets/frames (target ID == frame ID for OOPIFs)
31
+ # that are being initialized.
32
+ @frames_pending_target_init = {}
33
+
34
+ # Keeps track of frames that are in the process of being attached in #onFrameAttached.
35
+ @frames_pending_attachment = {}
36
+
30
37
  setup_listeners(@client)
31
38
  end
32
39
 
@@ -61,27 +68,17 @@ class Puppeteer::FrameManager
61
68
  client.on_event('Page.lifecycleEvent') do |event|
62
69
  handle_lifecycle_event(event)
63
70
  end
64
- client.on_event('Target.attachedToTarget') do |event|
65
- handle_attached_to_target(event)
66
- end
67
- client.on_event('Target.detachedFromTarget') do |event|
68
- handle_detached_from_target(event)
69
- end
70
71
  end
71
72
 
72
73
  attr_reader :client, :timeout_settings
73
74
 
74
- private def init(cdp_session = nil)
75
+ private def init(target_id, cdp_session = nil)
76
+ @frames_pending_target_init[target_id] ||= resolvable_future
75
77
  client = cdp_session || @client
76
78
 
77
79
  promises = [
78
80
  client.async_send_message('Page.enable'),
79
81
  client.async_send_message('Page.getFrameTree'),
80
- cdp_session&.async_send_message('Target.setAutoAttach', {
81
- autoAttach: true,
82
- waitForDebuggerOnStart: false,
83
- flatten: true,
84
- }),
85
82
  ].compact
86
83
  results = await_all(*promises)
87
84
  frame_tree = results[1]['frameTree']
@@ -97,6 +94,8 @@ class Puppeteer::FrameManager
97
94
  return if err.message.include?('Target closed') || err.message.include?('Session closed')
98
95
 
99
96
  raise
97
+ ensure
98
+ @frames_pending_target_init.delete(target_id)&.fulfill(nil)
100
99
  end
101
100
 
102
101
  define_async_method :async_init
@@ -121,12 +120,13 @@ class Puppeteer::FrameManager
121
120
  option_timeout = timeout || @timeout_settings.navigation_timeout
122
121
 
123
122
  watcher = Puppeteer::LifecycleWatcher.new(self, frame, option_wait_until, option_timeout)
123
+ ensure_new_document_navigation = false
124
124
 
125
125
  begin
126
126
  navigate = future do
127
127
  result = @client.send_message('Page.navigate', navigate_params)
128
128
  loader_id = result['loaderId']
129
-
129
+ ensure_new_document_navigation = !!loader_id
130
130
  if result['errorText']
131
131
  raise NavigationError.new("#{result['errorText']} at #{url}")
132
132
  end
@@ -137,9 +137,12 @@ class Puppeteer::FrameManager
137
137
  )
138
138
 
139
139
  await_any(
140
- watcher.new_document_navigation_promise,
141
- watcher.same_document_navigation_promise,
142
140
  watcher.timeout_or_termination_promise,
141
+ if ensure_new_document_navigation
142
+ watcher.new_document_navigation_promise
143
+ else
144
+ watcher.same_document_navigation_promise
145
+ end,
143
146
  )
144
147
  rescue Puppeteer::TimeoutError => err
145
148
  raise NavigationError.new(err)
@@ -175,20 +178,19 @@ class Puppeteer::FrameManager
175
178
  end
176
179
 
177
180
  # @param event [Hash]
178
- def handle_attached_to_target(event)
179
- return if event['targetInfo']['type'] != 'iframe'
180
-
181
- frame = @frames[event['targetInfo']['targetId']]
182
- session = Puppeteer::Connection.from_session(@client).session(event['sessionId'])
181
+ def handle_attached_to_target(target)
182
+ return if target.target_info.type != 'iframe'
183
183
 
184
+ frame = @frames[target.target_info.target_id]
185
+ session = target.session
184
186
  frame&.send(:update_client, session)
185
187
  setup_listeners(session)
186
- async_init(session)
188
+ async_init(target.target_info.target_id, session)
187
189
  end
188
190
 
189
191
  # @param event [Hash]
190
- def handle_detached_from_target(event)
191
- frame = @frames[event['targetId']]
192
+ def handle_detached_from_target(target)
193
+ frame = @frames[target.target_id]
192
194
  if frame && frame.oop_frame?
193
195
  # When an OOP iframe is removed from the page, it
194
196
  # will only get a Target.detachedFromTarget event.
@@ -256,7 +258,7 @@ class Puppeteer::FrameManager
256
258
 
257
259
  # @param session [Puppeteer::CDPSession]
258
260
  # @param frameId [String]
259
- # @param parentFrameId [String|nil]
261
+ # @param parentFrameId [String]
260
262
  def handle_frame_attached(session, frame_id, parent_frame_id)
261
263
  if @frames.has_key?(frame_id)
262
264
  frame = @frames[frame_id]
@@ -268,28 +270,57 @@ class Puppeteer::FrameManager
268
270
  end
269
271
  return
270
272
  end
271
- if !parent_frame_id
272
- raise ArgymentError.new('parent_frame_id must not be nil')
273
- end
274
273
  parent_frame = @frames[parent_frame_id]
275
- frame = Puppeteer::Frame.new(self, parent_frame, frame_id, session)
276
- @frames[frame_id] = frame
274
+ if parent_frame
275
+ attach_child_frame(parent_frame, parent_frame_id, frame_id, session)
276
+ return
277
+ end
277
278
 
279
+ if @frames_pending_target_init[parent_frame_id]
280
+ @frames_pending_attachment[frame_id] ||= resolvable_future
281
+ @frames_pending_target_init[parent_frame_id].then do |_|
282
+ attach_child_frame(@frames[parent_frame_id], parent_frame_id, frame_id, session)
283
+ @frames_pending_attachment.delete(frame_id)&.fulfill(nil)
284
+ end
285
+ return
286
+ end
287
+
288
+ raise FrameNotFoundError.new("Parent frame #{parent_frame_id} not found.")
289
+ end
290
+
291
+ class FrameNotFoundError < StandardError ; end
292
+
293
+ private def attach_child_frame(parent_frame, parent_frame_id, frame_id, session)
294
+ unless parent_frame
295
+ raise FrameNotFoundError.new("Parent frame #{parent_frame_id} not found.")
296
+ end
297
+
298
+ frame = Puppeteer::Frame.new(self, parent_frame, frame_id, session)
299
+ @frames[frame.id] = frame
278
300
  emit_event(FrameManagerEmittedEvents::FrameAttached, frame)
301
+ frame
279
302
  end
280
303
 
281
304
  # @param frame_payload [Hash]
282
305
  def handle_frame_navigated(frame_payload)
306
+ frame_id = frame_payload['id']
283
307
  is_main_frame = !frame_payload['parentId']
284
- frame =
285
- if is_main_frame
286
- @main_frame
287
- else
288
- @frames[frame_payload['id']]
308
+
309
+
310
+ if @frames_pending_attachment[frame_id]
311
+ @frames_pending_attachment[frame_id].then do |_|
312
+ frame = is_main_frame ? @main_frame : @frames[frame_id]
313
+ reattach_frame(frame, frame_id, is_main_frame, frame_payload)
289
314
  end
315
+ else
316
+ frame = is_main_frame ? @main_frame : @frames[frame_id]
317
+ reattach_frame(frame, frame_id, is_main_frame, frame_payload)
318
+ end
319
+ end
290
320
 
321
+ private def reattach_frame(frame, frame_id, is_main_frame, frame_payload)
291
322
  if !is_main_frame && !frame
292
- raise ArgumentError.new('We either navigate top level or have old version of the navigated frame')
323
+ raise "Missing frame isMainFrame=#{is_main_frame}, frameId=#{frame_id}"
293
324
  end
294
325
 
295
326
  # Detach all child frames first.
@@ -304,12 +335,12 @@ class Puppeteer::FrameManager
304
335
  if frame
305
336
  # Update frame id to retain frame identity on cross-process navigation.
306
337
  @frames.delete(frame.id)
307
- frame.id = frame_payload['id']
338
+ frame.id = frame_id
308
339
  else
309
340
  # Initial main frame navigation.
310
- frame = Puppeteer::Frame.new(self, nil, frame_payload['id'], @client)
341
+ frame = Puppeteer::Frame.new(self, nil, frame_id, @client)
311
342
  end
312
- @frames[frame_payload['id']] = frame
343
+ @frames[frame_id] = frame
313
344
  @main_frame = frame
314
345
  end
315
346