puppeteer-bidi 0.0.1.beta1
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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/CLAUDE/README.md +158 -0
- data/CLAUDE/async_programming.md +158 -0
- data/CLAUDE/click_implementation.md +340 -0
- data/CLAUDE/core_layer_gotchas.md +136 -0
- data/CLAUDE/error_handling.md +232 -0
- data/CLAUDE/file_chooser.md +95 -0
- data/CLAUDE/frame_architecture.md +346 -0
- data/CLAUDE/javascript_evaluation.md +341 -0
- data/CLAUDE/jshandle_implementation.md +505 -0
- data/CLAUDE/keyboard_implementation.md +250 -0
- data/CLAUDE/mouse_implementation.md +140 -0
- data/CLAUDE/navigation_waiting.md +234 -0
- data/CLAUDE/porting_puppeteer.md +214 -0
- data/CLAUDE/query_handler.md +194 -0
- data/CLAUDE/rspec_pending_vs_skip.md +262 -0
- data/CLAUDE/selector_evaluation.md +198 -0
- data/CLAUDE/test_server_routes.md +263 -0
- data/CLAUDE/testing_strategy.md +236 -0
- data/CLAUDE/two_layer_architecture.md +180 -0
- data/CLAUDE/wrapped_element_click.md +247 -0
- data/CLAUDE.md +185 -0
- data/LICENSE.txt +21 -0
- data/README.md +488 -0
- data/Rakefile +21 -0
- data/lib/puppeteer/bidi/async_utils.rb +151 -0
- data/lib/puppeteer/bidi/browser.rb +285 -0
- data/lib/puppeteer/bidi/browser_context.rb +53 -0
- data/lib/puppeteer/bidi/browser_launcher.rb +240 -0
- data/lib/puppeteer/bidi/connection.rb +182 -0
- data/lib/puppeteer/bidi/core/README.md +169 -0
- data/lib/puppeteer/bidi/core/browser.rb +230 -0
- data/lib/puppeteer/bidi/core/browsing_context.rb +601 -0
- data/lib/puppeteer/bidi/core/disposable.rb +69 -0
- data/lib/puppeteer/bidi/core/errors.rb +64 -0
- data/lib/puppeteer/bidi/core/event_emitter.rb +83 -0
- data/lib/puppeteer/bidi/core/navigation.rb +128 -0
- data/lib/puppeteer/bidi/core/realm.rb +315 -0
- data/lib/puppeteer/bidi/core/request.rb +300 -0
- data/lib/puppeteer/bidi/core/session.rb +153 -0
- data/lib/puppeteer/bidi/core/user_context.rb +208 -0
- data/lib/puppeteer/bidi/core/user_prompt.rb +102 -0
- data/lib/puppeteer/bidi/core.rb +45 -0
- data/lib/puppeteer/bidi/deserializer.rb +132 -0
- data/lib/puppeteer/bidi/element_handle.rb +602 -0
- data/lib/puppeteer/bidi/errors.rb +42 -0
- data/lib/puppeteer/bidi/file_chooser.rb +52 -0
- data/lib/puppeteer/bidi/frame.rb +597 -0
- data/lib/puppeteer/bidi/http_response.rb +23 -0
- data/lib/puppeteer/bidi/injected.js +1 -0
- data/lib/puppeteer/bidi/injected_source.rb +21 -0
- data/lib/puppeteer/bidi/js_handle.rb +302 -0
- data/lib/puppeteer/bidi/keyboard.rb +265 -0
- data/lib/puppeteer/bidi/lazy_arg.rb +23 -0
- data/lib/puppeteer/bidi/mouse.rb +170 -0
- data/lib/puppeteer/bidi/page.rb +613 -0
- data/lib/puppeteer/bidi/query_handler.rb +397 -0
- data/lib/puppeteer/bidi/realm.rb +242 -0
- data/lib/puppeteer/bidi/serializer.rb +139 -0
- data/lib/puppeteer/bidi/target.rb +81 -0
- data/lib/puppeteer/bidi/task_manager.rb +44 -0
- data/lib/puppeteer/bidi/timeout_settings.rb +20 -0
- data/lib/puppeteer/bidi/transport.rb +129 -0
- data/lib/puppeteer/bidi/version.rb +7 -0
- data/lib/puppeteer/bidi/wait_task.rb +322 -0
- data/lib/puppeteer/bidi.rb +49 -0
- data/scripts/update_injected_source.rb +57 -0
- data/sig/puppeteer/bidi/browser.rbs +80 -0
- data/sig/puppeteer/bidi/element_handle.rbs +238 -0
- data/sig/puppeteer/bidi/frame.rbs +205 -0
- data/sig/puppeteer/bidi/js_handle.rbs +90 -0
- data/sig/puppeteer/bidi/page.rbs +247 -0
- data/sig/puppeteer/bidi.rbs +15 -0
- metadata +176 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Puppeteer
|
|
4
|
+
module Bidi
|
|
5
|
+
module Core
|
|
6
|
+
# BrowsingContext represents a browsing context (tab, window, or iframe)
|
|
7
|
+
class BrowsingContext < EventEmitter
|
|
8
|
+
include Disposable::DisposableMixin
|
|
9
|
+
|
|
10
|
+
# Create a browsing context
|
|
11
|
+
# @param user_context [UserContext] Parent user context
|
|
12
|
+
# @param parent [BrowsingContext, nil] Parent browsing context
|
|
13
|
+
# @param id [String] Context ID
|
|
14
|
+
# @param url [String] Initial URL
|
|
15
|
+
# @param original_opener [String, nil] Original opener context ID
|
|
16
|
+
# @return [BrowsingContext] New browsing context
|
|
17
|
+
def self.from(user_context, parent, id, url, original_opener)
|
|
18
|
+
context = new(user_context, parent, id, url, original_opener)
|
|
19
|
+
context.send(:initialize_context)
|
|
20
|
+
context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :id, :user_context, :parent, :original_opener, :default_realm, :navigation, :inflight_requests
|
|
24
|
+
|
|
25
|
+
def initialize(user_context, parent, id, url, original_opener)
|
|
26
|
+
super()
|
|
27
|
+
@user_context = user_context
|
|
28
|
+
@parent = parent
|
|
29
|
+
@id = id
|
|
30
|
+
@url = url
|
|
31
|
+
@original_opener = original_opener
|
|
32
|
+
@reason = nil
|
|
33
|
+
@disposables = Disposable::DisposableStack.new
|
|
34
|
+
@children = {}
|
|
35
|
+
@realms = {}
|
|
36
|
+
@requests = {}
|
|
37
|
+
@navigation = nil
|
|
38
|
+
@emulation_state = { javascript_enabled: true }
|
|
39
|
+
@inflight_requests = 0
|
|
40
|
+
@inflight_mutex = Thread::Mutex.new
|
|
41
|
+
|
|
42
|
+
@default_realm = WindowRealm.from(self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if the context is closed
|
|
46
|
+
def closed?
|
|
47
|
+
!@reason.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
alias disposed? closed?
|
|
51
|
+
|
|
52
|
+
# Get the current URL
|
|
53
|
+
# @return [String] Current URL
|
|
54
|
+
def url
|
|
55
|
+
@url
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get child browsing contexts
|
|
59
|
+
# @return [Array<BrowsingContext>] Child contexts
|
|
60
|
+
def children
|
|
61
|
+
@children.values
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get all realms in this context
|
|
65
|
+
# @return [Array<WindowRealm>] All realms
|
|
66
|
+
def realms
|
|
67
|
+
[@default_realm] + @realms.values
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get the top-level browsing context
|
|
71
|
+
# @return [BrowsingContext] Top-level context
|
|
72
|
+
def top
|
|
73
|
+
context = self
|
|
74
|
+
while context.parent
|
|
75
|
+
context = context.parent
|
|
76
|
+
end
|
|
77
|
+
context
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Activate this browsing context
|
|
81
|
+
def activate
|
|
82
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
83
|
+
session.async_send_command('browsingContext.activate', { context: @id })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Navigate to a URL
|
|
87
|
+
# @param url [String] URL to navigate to
|
|
88
|
+
# @param wait [String, nil] Wait condition ('none', 'interactive', 'complete')
|
|
89
|
+
def navigate(url, wait: nil)
|
|
90
|
+
Async do
|
|
91
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
92
|
+
params = { context: @id, url: url }
|
|
93
|
+
params[:wait] = wait if wait
|
|
94
|
+
result = session.async_send_command('browsingContext.navigate', params).wait
|
|
95
|
+
# URL will be updated via browsingContext.load event
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reload the page
|
|
101
|
+
# @param options [Hash] Reload options
|
|
102
|
+
def reload(**options)
|
|
103
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
104
|
+
session.async_send_command('browsingContext.reload', options.merge(context: @id))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Capture a screenshot
|
|
108
|
+
# @param options [Hash] Screenshot options
|
|
109
|
+
# @return [Async::Task<String>] Base64-encoded image data
|
|
110
|
+
def capture_screenshot(**options)
|
|
111
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
112
|
+
Async do
|
|
113
|
+
result = session.async_send_command('browsingContext.captureScreenshot', options.merge(context: @id)).wait
|
|
114
|
+
result['data']
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Print to PDF
|
|
119
|
+
# @param options [Hash] Print options
|
|
120
|
+
# @return [Async::Task<String>] Base64-encoded PDF data
|
|
121
|
+
def print(**options)
|
|
122
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
123
|
+
Async do
|
|
124
|
+
result = session.async_send_command('browsingContext.print', options.merge(context: @id)).wait
|
|
125
|
+
result['data']
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Close this browsing context
|
|
130
|
+
# @param prompt_unload [Boolean] Whether to prompt before unload
|
|
131
|
+
def close(prompt_unload: false)
|
|
132
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
133
|
+
|
|
134
|
+
Async do
|
|
135
|
+
# Close all children first
|
|
136
|
+
children.each do |child|
|
|
137
|
+
begin
|
|
138
|
+
child.close(prompt_unload: prompt_unload).wait
|
|
139
|
+
rescue BrowsingContextClosedError
|
|
140
|
+
# Child already closed (e.g., iframe removed mid-operation)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Send close command
|
|
145
|
+
# Note: For non-top-level contexts (iframes), this may fail with
|
|
146
|
+
# "Browsing context ... is not top-level" error, which is expected
|
|
147
|
+
# because parent closure automatically closes children in BiDi protocol
|
|
148
|
+
begin
|
|
149
|
+
session.async_send_command('browsingContext.close', {
|
|
150
|
+
context: @id,
|
|
151
|
+
promptUnload: prompt_unload
|
|
152
|
+
})
|
|
153
|
+
rescue Connection::ProtocolError => e
|
|
154
|
+
# Ignore "not top-level" errors for iframe contexts
|
|
155
|
+
# This happens when parent context closes and BiDi auto-closes children
|
|
156
|
+
# The error message is in format: "BiDi error (browsingContext.close): Browsing context with id ... is not top-level"
|
|
157
|
+
if ENV['DEBUG_BIDI_COMMAND']
|
|
158
|
+
puts "[BiDi] Close error for context #{@id}: #{e.message.inspect}"
|
|
159
|
+
end
|
|
160
|
+
raise unless e.message.include?('is not top-level')
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Traverse history
|
|
166
|
+
# @param delta [Integer] Number of steps to go back (negative) or forward (positive)
|
|
167
|
+
def traverse_history(delta)
|
|
168
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
169
|
+
session.async_send_command('browsingContext.traverseHistory', {
|
|
170
|
+
context: @id,
|
|
171
|
+
delta: delta
|
|
172
|
+
})
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Handle a user prompt
|
|
176
|
+
# @param options [Hash] Prompt handling options
|
|
177
|
+
def handle_user_prompt(**options)
|
|
178
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
179
|
+
session.async_send_command('browsingContext.handleUserPrompt', options.merge(context: @id))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Set viewport
|
|
183
|
+
# @param options [Hash] Viewport options
|
|
184
|
+
def set_viewport(**options)
|
|
185
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
186
|
+
session.async_send_command('browsingContext.setViewport', options.merge(context: @id))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Perform input actions
|
|
190
|
+
# @param actions [Array<Hash>] Input actions
|
|
191
|
+
def perform_actions(actions)
|
|
192
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
193
|
+
session.async_send_command('input.performActions', {
|
|
194
|
+
context: @id,
|
|
195
|
+
actions: actions
|
|
196
|
+
})
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Release input actions
|
|
200
|
+
def release_actions
|
|
201
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
202
|
+
session.async_send_command('input.releaseActions', { context: @id })
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Set cache behavior
|
|
206
|
+
# @param cache_behavior [String] 'default' or 'bypass'
|
|
207
|
+
def set_cache_behavior(cache_behavior)
|
|
208
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
209
|
+
session.async_send_command('network.setCacheBehavior', {
|
|
210
|
+
contexts: [@id],
|
|
211
|
+
cacheBehavior: cache_behavior
|
|
212
|
+
})
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Create a sandboxed window realm
|
|
216
|
+
# @param sandbox [String] Sandbox name
|
|
217
|
+
# @return [WindowRealm] New realm
|
|
218
|
+
def create_window_realm(sandbox)
|
|
219
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
220
|
+
realm = WindowRealm.from(self, sandbox)
|
|
221
|
+
realm.on(:worker) do |worker_realm|
|
|
222
|
+
emit(:worker, { realm: worker_realm })
|
|
223
|
+
end
|
|
224
|
+
realm
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Add a preload script to this context
|
|
228
|
+
# @param function_declaration [String] JavaScript function
|
|
229
|
+
# @param options [Hash] Script options
|
|
230
|
+
# @return [String] Script ID
|
|
231
|
+
def add_preload_script(function_declaration, **options)
|
|
232
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
233
|
+
user_context.browser.add_preload_script(
|
|
234
|
+
function_declaration,
|
|
235
|
+
**options.merge(contexts: [self])
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Remove a preload script
|
|
240
|
+
# @param script [String] Script ID
|
|
241
|
+
def remove_preload_script(script)
|
|
242
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
243
|
+
user_context.browser.remove_preload_script(script)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Add network intercept
|
|
247
|
+
# @param options [Hash] Intercept options
|
|
248
|
+
# @return [String] Intercept ID
|
|
249
|
+
def add_intercept(**options)
|
|
250
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
251
|
+
result = session.async_send_command('network.addIntercept', options.merge(contexts: [@id]))
|
|
252
|
+
result['intercept']
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Get cookies
|
|
256
|
+
# @param options [Hash] Cookie filter options
|
|
257
|
+
# @return [Array<Hash>] Cookies
|
|
258
|
+
def get_cookies(**options)
|
|
259
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
260
|
+
params = options.dup
|
|
261
|
+
params[:partition] = {
|
|
262
|
+
type: 'context',
|
|
263
|
+
context: @id
|
|
264
|
+
}
|
|
265
|
+
result = session.async_send_command('storage.getCookies', params)
|
|
266
|
+
result['cookies']
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Set a cookie
|
|
270
|
+
# @param cookie [Hash] Cookie data
|
|
271
|
+
def set_cookie(cookie)
|
|
272
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
273
|
+
session.async_send_command('storage.setCookie', {
|
|
274
|
+
cookie: cookie,
|
|
275
|
+
partition: {
|
|
276
|
+
type: 'context',
|
|
277
|
+
context: @id
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Delete cookies
|
|
283
|
+
# @param cookie_filters [Array<Hash>] Cookie filters
|
|
284
|
+
def delete_cookie(*cookie_filters)
|
|
285
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
286
|
+
cookie_filters.each do |filter|
|
|
287
|
+
session.async_send_command('storage.deleteCookies', {
|
|
288
|
+
filter: filter,
|
|
289
|
+
partition: {
|
|
290
|
+
type: 'context',
|
|
291
|
+
context: @id
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Set geolocation override
|
|
298
|
+
# @param options [Hash] Geolocation options
|
|
299
|
+
def set_geolocation_override(**options)
|
|
300
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
301
|
+
raise 'Missing coordinates' unless options[:coordinates]
|
|
302
|
+
|
|
303
|
+
session.async_send_command('emulation.setGeolocationOverride', {
|
|
304
|
+
coordinates: options[:coordinates],
|
|
305
|
+
contexts: [@id]
|
|
306
|
+
})
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Set timezone override
|
|
310
|
+
# @param timezone_id [String, nil] Timezone ID
|
|
311
|
+
def set_timezone_override(timezone_id = nil)
|
|
312
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
313
|
+
|
|
314
|
+
# Remove GMT prefix for interop between CDP and BiDi
|
|
315
|
+
timezone_id = timezone_id&.sub(/^GMT/, '')
|
|
316
|
+
|
|
317
|
+
session.async_send_command('emulation.setTimezoneOverride', {
|
|
318
|
+
timezone: timezone_id,
|
|
319
|
+
contexts: [@id]
|
|
320
|
+
})
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Set files on an input element
|
|
324
|
+
# @param element [Hash] Element reference
|
|
325
|
+
# @param files [Array<String>] File paths
|
|
326
|
+
def set_files(element, files)
|
|
327
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
328
|
+
session.async_send_command('input.setFiles', {
|
|
329
|
+
context: @id,
|
|
330
|
+
element: element,
|
|
331
|
+
files: files
|
|
332
|
+
})
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Locate nodes in the context
|
|
336
|
+
# @param locator [Hash] Node locator
|
|
337
|
+
# @param start_nodes [Array<Hash>] Starting nodes
|
|
338
|
+
# @return [Array<Hash>] Located nodes
|
|
339
|
+
def locate_nodes(locator, start_nodes = [])
|
|
340
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
341
|
+
params = {
|
|
342
|
+
context: @id,
|
|
343
|
+
locator: locator
|
|
344
|
+
}
|
|
345
|
+
params[:startNodes] = start_nodes unless start_nodes.empty?
|
|
346
|
+
|
|
347
|
+
result = session.async_send_command('browsingContext.locateNodes', params)
|
|
348
|
+
result['nodes']
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Set JavaScript enabled state
|
|
352
|
+
# @param enabled [Boolean] Whether JavaScript is enabled
|
|
353
|
+
def set_javascript_enabled(enabled)
|
|
354
|
+
Async do
|
|
355
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
356
|
+
session.async_send_command('emulation.setScriptingEnabled', {
|
|
357
|
+
enabled: enabled ? nil : false,
|
|
358
|
+
contexts: [@id]
|
|
359
|
+
}).wait
|
|
360
|
+
@emulation_state[:javascript_enabled] = enabled
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Check if JavaScript is enabled
|
|
365
|
+
# @return [Boolean] Whether JavaScript is enabled
|
|
366
|
+
def javascript_enabled?
|
|
367
|
+
@emulation_state[:javascript_enabled]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Subscribe to events for this context
|
|
371
|
+
# @param events [Array<String>] Event names
|
|
372
|
+
def subscribe(events)
|
|
373
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
374
|
+
session.subscribe(events, [@id])
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Add interception for this context
|
|
378
|
+
# @param events [Array<String>] Event names
|
|
379
|
+
def add_interception(events)
|
|
380
|
+
raise BrowsingContextClosedError, @reason if closed?
|
|
381
|
+
session.subscribe(events, [@id])
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Override dispose to emit :closed event before setting @disposed = true
|
|
385
|
+
# This is needed because EventEmitter#emit returns early if @disposed is true
|
|
386
|
+
def dispose
|
|
387
|
+
return if disposed?
|
|
388
|
+
|
|
389
|
+
@reason ||= 'Browsing context closed, probably because the user context closed'
|
|
390
|
+
emit(:closed, { reason: @reason })
|
|
391
|
+
|
|
392
|
+
super # This sets @disposed = true and calls perform_dispose
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
protected
|
|
396
|
+
|
|
397
|
+
def perform_dispose
|
|
398
|
+
dispose_children('Parent browsing context was disposed')
|
|
399
|
+
|
|
400
|
+
begin
|
|
401
|
+
@default_realm.dispose unless @default_realm&.disposed?
|
|
402
|
+
rescue StandardError
|
|
403
|
+
# Ignore realm disposal failures during shutdown
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
@realms.values.each do |realm|
|
|
407
|
+
begin
|
|
408
|
+
realm.dispose unless realm.disposed?
|
|
409
|
+
rescue StandardError
|
|
410
|
+
# Ignore per-realm cleanup errors
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
@realms.clear
|
|
414
|
+
|
|
415
|
+
@disposables.dispose
|
|
416
|
+
super
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
private
|
|
420
|
+
|
|
421
|
+
def session
|
|
422
|
+
@user_context.browser.session
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def initialize_context
|
|
426
|
+
# Listen for user context closure
|
|
427
|
+
@user_context.once(:closed) do |data|
|
|
428
|
+
dispose_context("Browsing context closed: #{data[:reason]}")
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Listen for various browsing context events
|
|
432
|
+
setup_event_listeners
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def setup_event_listeners
|
|
436
|
+
# Context destroyed
|
|
437
|
+
session.on('browsingContext.contextDestroyed') do |info|
|
|
438
|
+
next unless info['context'] == @id
|
|
439
|
+
dispose_children('Parent browsing context was disposed')
|
|
440
|
+
dispose_context('Browsing context already closed')
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Child context created
|
|
444
|
+
session.on('browsingContext.contextCreated') do |info|
|
|
445
|
+
next unless info['parent'] == @id
|
|
446
|
+
|
|
447
|
+
child_context = BrowsingContext.from(
|
|
448
|
+
@user_context,
|
|
449
|
+
self,
|
|
450
|
+
info['context'],
|
|
451
|
+
info['url'],
|
|
452
|
+
info['originalOpener']
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
@children[child_context.id] = child_context
|
|
456
|
+
|
|
457
|
+
child_context.once(:closed) do
|
|
458
|
+
@children.delete(child_context.id)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
emit(:browsingcontext, { browsing_context: child_context })
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# History updated
|
|
465
|
+
session.on('browsingContext.historyUpdated') do |info|
|
|
466
|
+
next unless info['context'] == @id
|
|
467
|
+
@url = info['url']
|
|
468
|
+
emit(:history_updated, nil)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Fragment navigated (anchor links, hash changes)
|
|
472
|
+
session.on('browsingContext.fragmentNavigated') do |info|
|
|
473
|
+
next unless info['context'] == @id
|
|
474
|
+
@url = info['url']
|
|
475
|
+
emit(:fragment_navigated, nil)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# DOM content loaded
|
|
479
|
+
session.on('browsingContext.domContentLoaded') do |info|
|
|
480
|
+
next unless info['context'] == @id
|
|
481
|
+
@url = info['url']
|
|
482
|
+
emit(:dom_content_loaded, nil)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Page loaded
|
|
486
|
+
session.on('browsingContext.load') do |info|
|
|
487
|
+
next unless info['context'] == @id
|
|
488
|
+
@url = info['url']
|
|
489
|
+
emit(:load, nil)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Navigation started
|
|
493
|
+
session.on('browsingContext.navigationStarted') do |info|
|
|
494
|
+
next unless info['context'] == @id
|
|
495
|
+
|
|
496
|
+
# Clean up disposed requests
|
|
497
|
+
@requests.delete_if { |_, request| request.disposed? }
|
|
498
|
+
|
|
499
|
+
# Skip if navigation hasn't finished
|
|
500
|
+
next if @navigation && !@navigation.disposed?
|
|
501
|
+
|
|
502
|
+
# Create new navigation
|
|
503
|
+
@navigation = Navigation.from(self)
|
|
504
|
+
|
|
505
|
+
# Wrap navigation in EventEmitter and register with disposables
|
|
506
|
+
# This follows Puppeteer's pattern: new EventEmitter(this.#navigation)
|
|
507
|
+
navigation_emitter = EventEmitter.new
|
|
508
|
+
@disposables.use(navigation_emitter)
|
|
509
|
+
|
|
510
|
+
# Listen for navigation completion events to update URL
|
|
511
|
+
# Puppeteer: for (const eventName of ['fragment', 'failed', 'aborted'])
|
|
512
|
+
[:fragment, :failed, :aborted].each do |event_name|
|
|
513
|
+
@navigation.once(event_name) do |data|
|
|
514
|
+
navigation_emitter.dispose
|
|
515
|
+
@url = data[:url]
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Emit navigation event for subscribers (e.g., Frame#wait_for_navigation)
|
|
520
|
+
emit(:navigation, { navigation: @navigation })
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Network events
|
|
524
|
+
session.on('network.beforeRequestSent') do |event|
|
|
525
|
+
next unless event['context'] == @id
|
|
526
|
+
|
|
527
|
+
request_id = event['request']['request']
|
|
528
|
+
next if @requests.key?(request_id)
|
|
529
|
+
|
|
530
|
+
@requests[request_id] = true
|
|
531
|
+
|
|
532
|
+
# Increment inflight requests counter
|
|
533
|
+
@inflight_mutex.synchronize do
|
|
534
|
+
@inflight_requests += 1
|
|
535
|
+
emit(:inflight_changed, { inflight: @inflight_requests })
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
session.on('network.responseCompleted') do |event|
|
|
540
|
+
next unless event['context'] == @id
|
|
541
|
+
|
|
542
|
+
request_id = event['request']['request']
|
|
543
|
+
next unless @requests.delete(request_id)
|
|
544
|
+
|
|
545
|
+
# Decrement inflight requests counter
|
|
546
|
+
@inflight_mutex.synchronize do
|
|
547
|
+
@inflight_requests -= 1
|
|
548
|
+
emit(:inflight_changed, { inflight: @inflight_requests })
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
session.on('network.fetchError') do |event|
|
|
553
|
+
next unless event['context'] == @id
|
|
554
|
+
|
|
555
|
+
request_id = event['request']['request']
|
|
556
|
+
next unless @requests.delete(request_id)
|
|
557
|
+
|
|
558
|
+
# Decrement inflight requests counter
|
|
559
|
+
@inflight_mutex.synchronize do
|
|
560
|
+
@inflight_requests -= 1
|
|
561
|
+
emit(:inflight_changed, { inflight: @inflight_requests })
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Log entries
|
|
566
|
+
session.on('log.entryAdded') do |entry|
|
|
567
|
+
next unless entry.dig('source', 'context') == @id
|
|
568
|
+
emit(:log, { entry: entry })
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# User prompts
|
|
572
|
+
session.on('browsingContext.userPromptOpened') do |info|
|
|
573
|
+
next unless info['context'] == @id
|
|
574
|
+
# user_prompt = UserPrompt.from(self, info)
|
|
575
|
+
# emit(:userprompt, { user_prompt: user_prompt })
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# File dialog
|
|
579
|
+
session.on('input.fileDialogOpened') do |info|
|
|
580
|
+
next unless info['context'] == @id
|
|
581
|
+
emit(:filedialogopened, info)
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def dispose_children(reason)
|
|
586
|
+
@children.values.each do |child|
|
|
587
|
+
next if child.closed?
|
|
588
|
+
child.send(:dispose_context, reason)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def dispose_context(reason)
|
|
593
|
+
# IMPORTANT: Set @reason AFTER calling dispose to avoid early return
|
|
594
|
+
# dispose checks disposed? which is aliased to closed?, and closed? returns !@reason.nil?
|
|
595
|
+
dispose
|
|
596
|
+
@reason = reason
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Puppeteer
|
|
4
|
+
module Bidi
|
|
5
|
+
module Core
|
|
6
|
+
# Disposable provides resource management and cleanup capabilities
|
|
7
|
+
# Similar to TypeScript's DisposableStack
|
|
8
|
+
module Disposable
|
|
9
|
+
# DisposableStack manages multiple disposable resources
|
|
10
|
+
class DisposableStack
|
|
11
|
+
def initialize
|
|
12
|
+
@disposed = false
|
|
13
|
+
@resources = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Add a disposable resource to the stack
|
|
17
|
+
# @param resource [Object] Resource that responds to #dispose
|
|
18
|
+
# @return [Object] The resource itself for convenience
|
|
19
|
+
def use(resource)
|
|
20
|
+
raise 'DisposableStack already disposed' if @disposed
|
|
21
|
+
@resources << resource
|
|
22
|
+
resource
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Dispose all resources in reverse order (LIFO)
|
|
26
|
+
def dispose
|
|
27
|
+
return if @disposed
|
|
28
|
+
@disposed = true
|
|
29
|
+
|
|
30
|
+
# Dispose in reverse order
|
|
31
|
+
@resources.reverse_each do |resource|
|
|
32
|
+
begin
|
|
33
|
+
resource.dispose if resource.respond_to?(:dispose)
|
|
34
|
+
rescue => e
|
|
35
|
+
warn "Error disposing resource: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@resources.clear
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def disposed?
|
|
43
|
+
@disposed
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Module to be included in classes that need disposal
|
|
48
|
+
module DisposableMixin
|
|
49
|
+
def dispose
|
|
50
|
+
return if @disposed
|
|
51
|
+
@disposed = true
|
|
52
|
+
perform_dispose
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def disposed?
|
|
56
|
+
@disposed ||= false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
protected
|
|
60
|
+
|
|
61
|
+
# Override this method to perform cleanup
|
|
62
|
+
def perform_dispose
|
|
63
|
+
# Default implementation does nothing
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|