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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +13 -0
  4. data/CLAUDE/README.md +158 -0
  5. data/CLAUDE/async_programming.md +158 -0
  6. data/CLAUDE/click_implementation.md +340 -0
  7. data/CLAUDE/core_layer_gotchas.md +136 -0
  8. data/CLAUDE/error_handling.md +232 -0
  9. data/CLAUDE/file_chooser.md +95 -0
  10. data/CLAUDE/frame_architecture.md +346 -0
  11. data/CLAUDE/javascript_evaluation.md +341 -0
  12. data/CLAUDE/jshandle_implementation.md +505 -0
  13. data/CLAUDE/keyboard_implementation.md +250 -0
  14. data/CLAUDE/mouse_implementation.md +140 -0
  15. data/CLAUDE/navigation_waiting.md +234 -0
  16. data/CLAUDE/porting_puppeteer.md +214 -0
  17. data/CLAUDE/query_handler.md +194 -0
  18. data/CLAUDE/rspec_pending_vs_skip.md +262 -0
  19. data/CLAUDE/selector_evaluation.md +198 -0
  20. data/CLAUDE/test_server_routes.md +263 -0
  21. data/CLAUDE/testing_strategy.md +236 -0
  22. data/CLAUDE/two_layer_architecture.md +180 -0
  23. data/CLAUDE/wrapped_element_click.md +247 -0
  24. data/CLAUDE.md +185 -0
  25. data/LICENSE.txt +21 -0
  26. data/README.md +488 -0
  27. data/Rakefile +21 -0
  28. data/lib/puppeteer/bidi/async_utils.rb +151 -0
  29. data/lib/puppeteer/bidi/browser.rb +285 -0
  30. data/lib/puppeteer/bidi/browser_context.rb +53 -0
  31. data/lib/puppeteer/bidi/browser_launcher.rb +240 -0
  32. data/lib/puppeteer/bidi/connection.rb +182 -0
  33. data/lib/puppeteer/bidi/core/README.md +169 -0
  34. data/lib/puppeteer/bidi/core/browser.rb +230 -0
  35. data/lib/puppeteer/bidi/core/browsing_context.rb +601 -0
  36. data/lib/puppeteer/bidi/core/disposable.rb +69 -0
  37. data/lib/puppeteer/bidi/core/errors.rb +64 -0
  38. data/lib/puppeteer/bidi/core/event_emitter.rb +83 -0
  39. data/lib/puppeteer/bidi/core/navigation.rb +128 -0
  40. data/lib/puppeteer/bidi/core/realm.rb +315 -0
  41. data/lib/puppeteer/bidi/core/request.rb +300 -0
  42. data/lib/puppeteer/bidi/core/session.rb +153 -0
  43. data/lib/puppeteer/bidi/core/user_context.rb +208 -0
  44. data/lib/puppeteer/bidi/core/user_prompt.rb +102 -0
  45. data/lib/puppeteer/bidi/core.rb +45 -0
  46. data/lib/puppeteer/bidi/deserializer.rb +132 -0
  47. data/lib/puppeteer/bidi/element_handle.rb +602 -0
  48. data/lib/puppeteer/bidi/errors.rb +42 -0
  49. data/lib/puppeteer/bidi/file_chooser.rb +52 -0
  50. data/lib/puppeteer/bidi/frame.rb +597 -0
  51. data/lib/puppeteer/bidi/http_response.rb +23 -0
  52. data/lib/puppeteer/bidi/injected.js +1 -0
  53. data/lib/puppeteer/bidi/injected_source.rb +21 -0
  54. data/lib/puppeteer/bidi/js_handle.rb +302 -0
  55. data/lib/puppeteer/bidi/keyboard.rb +265 -0
  56. data/lib/puppeteer/bidi/lazy_arg.rb +23 -0
  57. data/lib/puppeteer/bidi/mouse.rb +170 -0
  58. data/lib/puppeteer/bidi/page.rb +613 -0
  59. data/lib/puppeteer/bidi/query_handler.rb +397 -0
  60. data/lib/puppeteer/bidi/realm.rb +242 -0
  61. data/lib/puppeteer/bidi/serializer.rb +139 -0
  62. data/lib/puppeteer/bidi/target.rb +81 -0
  63. data/lib/puppeteer/bidi/task_manager.rb +44 -0
  64. data/lib/puppeteer/bidi/timeout_settings.rb +20 -0
  65. data/lib/puppeteer/bidi/transport.rb +129 -0
  66. data/lib/puppeteer/bidi/version.rb +7 -0
  67. data/lib/puppeteer/bidi/wait_task.rb +322 -0
  68. data/lib/puppeteer/bidi.rb +49 -0
  69. data/scripts/update_injected_source.rb +57 -0
  70. data/sig/puppeteer/bidi/browser.rbs +80 -0
  71. data/sig/puppeteer/bidi/element_handle.rbs +238 -0
  72. data/sig/puppeteer/bidi/frame.rbs +205 -0
  73. data/sig/puppeteer/bidi/js_handle.rbs +90 -0
  74. data/sig/puppeteer/bidi/page.rbs +247 -0
  75. data/sig/puppeteer/bidi.rbs +15 -0
  76. 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