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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # Request represents a network request
7
+ class Request < EventEmitter
8
+ include Disposable::DisposableMixin
9
+
10
+ # Create a request instance from a beforeRequestSent event
11
+ # @param browsing_context [BrowsingContext] The browsing context
12
+ # @param event [Hash] The beforeRequestSent event data
13
+ # @return [Request] New request instance
14
+ def self.from(browsing_context, event)
15
+ request = new(browsing_context, event)
16
+ request.send(:initialize_request)
17
+ request
18
+ end
19
+
20
+ attr_reader :browsing_context, :error, :response
21
+
22
+ def initialize(browsing_context, event)
23
+ super()
24
+ @browsing_context = browsing_context
25
+ @event = event
26
+ @error = nil
27
+ @redirect = nil
28
+ @response = nil
29
+ @response_content_promise = nil
30
+ @request_body_promise = nil
31
+ @disposables = Disposable::DisposableStack.new
32
+ end
33
+
34
+ # Get request ID
35
+ # @return [String] Request ID
36
+ def id
37
+ @event.dig('request', 'request')
38
+ end
39
+
40
+ # Get request URL
41
+ # @return [String] Request URL
42
+ def url
43
+ @event.dig('request', 'url')
44
+ end
45
+
46
+ # Get request method
47
+ # @return [String] Request method (GET, POST, etc.)
48
+ def method
49
+ @event.dig('request', 'method')
50
+ end
51
+
52
+ # Get request headers
53
+ # @return [Array<Hash>] Request headers
54
+ def headers
55
+ @event.dig('request', 'headers') || []
56
+ end
57
+
58
+ # Get navigation ID if this is a navigation request
59
+ # @return [String, nil] Navigation ID
60
+ def navigation
61
+ @event['navigation']
62
+ end
63
+
64
+ # Get redirect request if this request was redirected
65
+ # @return [Request, nil] Redirect request
66
+ def redirect
67
+ @redirect
68
+ end
69
+
70
+ # Get the last redirect in the chain
71
+ # @return [Request, nil] Last redirect request
72
+ def last_redirect
73
+ redirect_request = @redirect
74
+ while redirect_request
75
+ break unless redirect_request.redirect
76
+ redirect_request = redirect_request.redirect
77
+ end
78
+ redirect_request
79
+ end
80
+
81
+ # Get request initiator information
82
+ # @return [Hash, nil] Initiator info
83
+ def initiator
84
+ initiator_data = @event['initiator']
85
+ return nil unless initiator_data
86
+
87
+ {
88
+ **initiator_data,
89
+ url: @event.dig('request', 'goog:resourceInitiator', 'url'),
90
+ stack: @event.dig('request', 'goog:resourceInitiator', 'stack')
91
+ }.compact
92
+ end
93
+
94
+ # Check if the request is blocked
95
+ # @return [Boolean] Whether the request is blocked
96
+ def blocked?
97
+ @event['isBlocked'] == true
98
+ end
99
+
100
+ # Get resource type (non-standard)
101
+ # @return [String, nil] Resource type
102
+ def resource_type
103
+ @event.dig('request', 'goog:resourceType')
104
+ end
105
+
106
+ # Get POST data (non-standard)
107
+ # @return [String, nil] POST data
108
+ def post_data
109
+ @event.dig('request', 'goog:postData')
110
+ end
111
+
112
+ # Check if request has POST data
113
+ # @return [Boolean] Whether request has POST data
114
+ def has_post_data?
115
+ (@event.dig('request', 'bodySize') || 0) > 0
116
+ end
117
+
118
+ # Get timing information
119
+ # @return [Hash] Timing info
120
+ def timing
121
+ @event.dig('request', 'timings') || {}
122
+ end
123
+
124
+ # Continue the request with optional modifications
125
+ # @param url [String, nil] Modified URL
126
+ # @param method [String, nil] Modified method
127
+ # @param headers [Array<Hash>, nil] Modified headers
128
+ # @param cookies [Array<Hash>, nil] Modified cookies
129
+ # @param body [Hash, nil] Modified body
130
+ def continue_request(url: nil, method: nil, headers: nil, cookies: nil, body: nil)
131
+ params = { request: id }
132
+ params[:url] = url if url
133
+ params[:method] = method if method
134
+ params[:headers] = headers if headers
135
+ params[:cookies] = cookies if cookies
136
+ params[:body] = body if body
137
+
138
+ session.send_command('network.continueRequest', params)
139
+ end
140
+
141
+ # Fail the request
142
+ def fail_request
143
+ session.send_command('network.failRequest', { request: id })
144
+ end
145
+
146
+ # Provide a response for the request
147
+ # @param status_code [Integer, nil] Response status code
148
+ # @param reason_phrase [String, nil] Response reason phrase
149
+ # @param headers [Array<Hash>, nil] Response headers
150
+ # @param body [Hash, nil] Response body
151
+ def provide_response(status_code: nil, reason_phrase: nil, headers: nil, body: nil)
152
+ params = { request: id }
153
+ params[:statusCode] = status_code if status_code
154
+ params[:reasonPhrase] = reason_phrase if reason_phrase
155
+ params[:headers] = headers if headers
156
+ params[:body] = body if body
157
+
158
+ session.send_command('network.provideResponse', params)
159
+ end
160
+
161
+ # Fetch POST data for the request
162
+ # @return [String, nil] POST data
163
+ def fetch_post_data
164
+ return nil unless has_post_data?
165
+ return @request_body_promise if @request_body_promise
166
+
167
+ @request_body_promise = begin
168
+ result = session.send_command('network.getData', {
169
+ dataType: 'request',
170
+ request: id
171
+ })
172
+
173
+ bytes = result['bytes']
174
+ if bytes['type'] == 'string'
175
+ bytes['value']
176
+ else
177
+ raise "Collected request body data of type #{bytes['type']} is not supported"
178
+ end
179
+ end
180
+ end
181
+
182
+ # Get response content
183
+ # @return [String] Response content as binary string
184
+ def response_content
185
+ return @response_content_promise if @response_content_promise
186
+
187
+ @response_content_promise = begin
188
+ result = session.send_command('network.getData', {
189
+ dataType: 'response',
190
+ request: id
191
+ })
192
+
193
+ bytes = result['bytes']
194
+ if bytes['type'] == 'base64'
195
+ [bytes['value']].pack('m0')
196
+ else
197
+ bytes['value']
198
+ end
199
+ rescue => e
200
+ if e.message.include?('No resource with given identifier found')
201
+ raise 'Could not load response body for this request. This might happen if the request is a preflight request.'
202
+ end
203
+ raise
204
+ end
205
+ end
206
+
207
+ # Continue with authentication
208
+ # @param action [String] 'provideCredentials', 'default', or 'cancel'
209
+ # @param credentials [Hash, nil] Credentials hash with username and password
210
+ def continue_with_auth(action:, credentials: nil)
211
+ params = {
212
+ request: id,
213
+ action: action
214
+ }
215
+ params[:credentials] = credentials if action == 'provideCredentials'
216
+
217
+ session.send_command('network.continueWithAuth', params)
218
+ end
219
+
220
+ protected
221
+
222
+ def perform_dispose
223
+ @disposables.dispose
224
+ super
225
+ end
226
+
227
+ private
228
+
229
+ def session
230
+ @browsing_context.user_context.browser.session
231
+ end
232
+
233
+ def initialize_request
234
+ # Listen for browsing context closure
235
+ @browsing_context.once(:closed) do |data|
236
+ @error = data[:reason]
237
+ emit(:error, @error)
238
+ dispose
239
+ end
240
+
241
+ # Listen for redirect
242
+ session.on('network.beforeRequestSent') do |event|
243
+ next unless event['context'] == @browsing_context.id
244
+ next unless event.dig('request', 'request') == id
245
+
246
+ # Check if this is a redirect
247
+ previous_has_auth = @event.dig('request', 'headers')&.any? do |h|
248
+ h['name'].downcase == 'authorization'
249
+ end
250
+ new_has_auth = event.dig('request', 'headers')&.any? do |h|
251
+ h['name'].downcase == 'authorization'
252
+ end
253
+ is_after_auth = new_has_auth && !previous_has_auth
254
+
255
+ next unless event['redirectCount'] == @event['redirectCount'] + 1 || is_after_auth
256
+
257
+ @redirect = Request.from(@browsing_context, event)
258
+ emit(:redirect, @redirect)
259
+ dispose
260
+ end
261
+
262
+ # Listen for authentication required
263
+ session.on('network.authRequired') do |event|
264
+ next unless event['context'] == @browsing_context.id
265
+ next unless event.dig('request', 'request') == id
266
+ next unless event['isBlocked']
267
+
268
+ emit(:authenticate, nil)
269
+ end
270
+
271
+ # Listen for fetch error
272
+ session.on('network.fetchError') do |event|
273
+ next unless event['context'] == @browsing_context.id
274
+ next unless event.dig('request', 'request') == id
275
+ next unless event['redirectCount'] == @event['redirectCount']
276
+
277
+ @error = event['errorText']
278
+ emit(:error, @error)
279
+ dispose
280
+ end
281
+
282
+ # Listen for response completed
283
+ session.on('network.responseCompleted') do |event|
284
+ next unless event['context'] == @browsing_context.id
285
+ next unless event.dig('request', 'request') == id
286
+ next unless event['redirectCount'] == @event['redirectCount']
287
+
288
+ @response = event['response']
289
+ @event['request']['timings'] = event.dig('request', 'timings')
290
+ emit(:success, @response)
291
+
292
+ # Don't dispose if this is a redirect
293
+ status = @response['status']
294
+ dispose unless status >= 300 && status < 400
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # Session represents a BiDi session with the browser
7
+ # It wraps a Connection and provides session-specific functionality
8
+ class Session < EventEmitter
9
+ include Disposable::DisposableMixin
10
+
11
+ # Create a new session from an existing connection
12
+ # @param connection [Puppeteer::Bidi::Connection] The BiDi connection
13
+ # @param capabilities [Hash] Session capabilities
14
+ # @return [Session] New session instance
15
+ def self.from(connection:, capabilities:)
16
+ Async do
17
+ result = connection.async_send_command('session.new', { capabilities: capabilities }).wait
18
+ session = new(connection, result)
19
+ session.send(:initialize_session).wait
20
+ session
21
+ end
22
+ end
23
+
24
+ attr_reader :connection, :id, :capabilities
25
+ attr_accessor :browser
26
+
27
+ def initialize(connection, info)
28
+ super()
29
+ @connection = connection
30
+ @info = info
31
+ @id = info['sessionId']
32
+ @capabilities = info['capabilities']
33
+ @reason = nil
34
+ @disposables = Disposable::DisposableStack.new
35
+
36
+ # Forward BiDi events from connection to session
37
+ setup_event_forwarding
38
+ end
39
+
40
+ # Check if the session has ended
41
+ def ended?
42
+ !@reason.nil?
43
+ end
44
+
45
+ alias disposed? ended?
46
+
47
+ # Send a BiDi command through this session
48
+ # @param method [String] BiDi method name
49
+ # @param params [Hash] Command parameters
50
+ # @return [Hash] Command result
51
+ def async_send_command(method, params = {})
52
+ raise SessionEndedError, @reason if ended?
53
+ @connection.async_send_command(method, params)
54
+ end
55
+
56
+ # Subscribe to BiDi events
57
+ # @param events [Array<String>] Event names to subscribe to
58
+ # @param contexts [Array<String>, nil] Context IDs (optional)
59
+ def subscribe(events, contexts = nil)
60
+ raise SessionEndedError, @reason if ended?
61
+ params = { events: events }
62
+ params[:contexts] = contexts if contexts
63
+ async_send_command('session.subscribe', params)
64
+ end
65
+
66
+ # Add intercepts (same as subscribe but for interception events)
67
+ # @param events [Array<String>] Event names to intercept
68
+ # @param contexts [Array<String>, nil] Context IDs (optional)
69
+ def add_intercepts(events, contexts = nil)
70
+ subscribe(events, contexts)
71
+ end
72
+
73
+ # End the session
74
+ def end_session
75
+ return if ended?
76
+
77
+ begin
78
+ send_command('session.end', {})
79
+ ensure
80
+ dispose_session('Session ended')
81
+ end
82
+ end
83
+
84
+ protected
85
+
86
+ def perform_dispose
87
+ @reason ||= 'Session destroyed, probably because the connection broke'
88
+ emit(:ended, { reason: @reason })
89
+ @disposables.dispose
90
+ super
91
+ end
92
+
93
+ private
94
+
95
+ def initialize_session
96
+ # Subscribe to BiDi modules
97
+ # Based on Puppeteer's subscribeModules: browsingContext, network, log, script, input
98
+ subscribe_modules = %w[
99
+ browsingContext
100
+ network
101
+ log
102
+ script
103
+ input
104
+ ]
105
+
106
+ subscribe(subscribe_modules)
107
+ end
108
+
109
+ def dispose_session(reason)
110
+ @reason = reason
111
+ dispose
112
+ end
113
+
114
+ def setup_event_forwarding
115
+ # Forward all BiDi events from connection to this session
116
+ # The existing Connection class uses #on method for event handling
117
+ # We need to set up listeners for all possible BiDi events
118
+
119
+ # For now, we'll use a workaround: store the connection's event listeners
120
+ # and forward to our EventEmitter
121
+
122
+ # List of common BiDi events to forward
123
+ bidi_events = [
124
+ 'browsingContext.contextCreated',
125
+ 'browsingContext.contextDestroyed',
126
+ 'browsingContext.navigationStarted',
127
+ 'browsingContext.fragmentNavigated',
128
+ 'browsingContext.domContentLoaded',
129
+ 'browsingContext.load',
130
+ 'browsingContext.historyUpdated',
131
+ 'browsingContext.userPromptOpened',
132
+ 'browsingContext.userPromptClosed',
133
+ 'network.beforeRequestSent',
134
+ 'network.responseStarted',
135
+ 'network.responseCompleted',
136
+ 'network.fetchError',
137
+ 'network.authRequired',
138
+ 'script.realmCreated',
139
+ 'script.realmDestroyed',
140
+ 'log.entryAdded',
141
+ 'input.fileDialogOpened',
142
+ ]
143
+
144
+ bidi_events.each do |event_name|
145
+ @connection.on(event_name) do |params|
146
+ emit(event_name.to_sym, params)
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # UserContext represents an isolated browsing context (like an incognito session)
7
+ class UserContext < EventEmitter
8
+ include Disposable::DisposableMixin
9
+
10
+ DEFAULT = 'default'
11
+
12
+ # Create a user context
13
+ # @param browser [Browser] Parent browser
14
+ # @param id [String] Context ID
15
+ # @return [UserContext] New user context
16
+ def self.create(browser, id)
17
+ context = new(browser, id)
18
+ context.send(:initialize_context)
19
+ context
20
+ end
21
+
22
+ attr_reader :browser, :id
23
+
24
+ def initialize(browser, id)
25
+ super()
26
+ @browser = browser
27
+ @id = id
28
+ @reason = nil
29
+ @disposables = Disposable::DisposableStack.new
30
+ @browsing_contexts = {}
31
+ end
32
+
33
+ # Check if the context is closed
34
+ def closed?
35
+ !@reason.nil?
36
+ end
37
+
38
+ alias disposed? closed?
39
+
40
+ # Get all browsing contexts in this user context
41
+ # @return [Array<BrowsingContext>] Top-level browsing contexts
42
+ def browsing_contexts
43
+ @browsing_contexts.values
44
+ end
45
+
46
+ # Create a new browsing context (tab or window)
47
+ # @param type [String] 'tab' or 'window'
48
+ # @param options [Hash] Creation options
49
+ # @option options [BrowsingContext] :reference_context Reference context
50
+ # @return [BrowsingContext] New browsing context
51
+ def create_browsing_context(type, **options)
52
+ raise UserContextClosedError, @reason if closed?
53
+
54
+ params = {
55
+ type: type,
56
+ userContext: @id
57
+ }
58
+ params[:referenceContext] = options[:reference_context].id if options[:reference_context]
59
+ params.merge!(options.except(:reference_context))
60
+
61
+ result = session.async_send_command('browsingContext.create', params).wait
62
+ context_id = result['context']
63
+
64
+ # Since event handling might be async or not working properly,
65
+ # check if the context was already created by the event handler
66
+ browsing_context = @browsing_contexts[context_id]
67
+
68
+ # If not created by event handler, create it manually
69
+ if browsing_context.nil?
70
+ browsing_context = BrowsingContext.from(
71
+ self,
72
+ nil, # parent
73
+ context_id,
74
+ 'about:blank', # Initial URL
75
+ nil # originalOpener
76
+ )
77
+ @browsing_contexts[context_id] = browsing_context
78
+
79
+ browsing_context.once(:closed) do
80
+ @browsing_contexts.delete(context_id)
81
+ end
82
+ end
83
+
84
+ browsing_context
85
+ end
86
+
87
+ # Remove this user context
88
+ def remove
89
+ return if closed?
90
+
91
+ begin
92
+ session.async_send_command('browser.removeUserContext', { userContext: @id })
93
+ ensure
94
+ dispose_context('User context removed')
95
+ end
96
+ end
97
+
98
+ # Get cookies for this user context
99
+ # @param options [Hash] Cookie filter options
100
+ # @option options [String] :source_origin Source origin
101
+ # @return [Array<Hash>] Cookies
102
+ def get_cookies(**options)
103
+ raise UserContextClosedError, @reason if closed?
104
+
105
+ source_origin = options.delete(:source_origin)
106
+ params = options.dup
107
+ params[:partition] = {
108
+ type: 'storageKey',
109
+ userContext: @id
110
+ }
111
+ params[:partition][:sourceOrigin] = source_origin if source_origin
112
+
113
+ result = session.async_send_command('storage.getCookies', params)
114
+ result['cookies']
115
+ end
116
+
117
+ # Set a cookie in this user context
118
+ # @param cookie [Hash] Cookie data
119
+ # @option options [String] :source_origin Source origin
120
+ def set_cookie(cookie, **options)
121
+ raise UserContextClosedError, @reason if closed?
122
+
123
+ source_origin = options[:source_origin]
124
+ params = {
125
+ cookie: cookie,
126
+ partition: {
127
+ type: 'storageKey',
128
+ userContext: @id
129
+ }
130
+ }
131
+ params[:partition][:sourceOrigin] = source_origin if source_origin
132
+
133
+ session.async_send_command('storage.setCookie', params)
134
+ end
135
+
136
+ # Set permissions for an origin
137
+ # @param origin [String] Origin URL
138
+ # @param descriptor [Hash] Permission descriptor
139
+ # @param state [String] Permission state
140
+ def set_permissions(origin, descriptor, state)
141
+ raise UserContextClosedError, @reason if closed?
142
+
143
+ session.async_send_command('permissions.setPermission', {
144
+ origin: origin,
145
+ descriptor: descriptor,
146
+ state: state,
147
+ userContext: @id
148
+ })
149
+ end
150
+
151
+ protected
152
+
153
+ def perform_dispose
154
+ @reason ||= 'User context closed, probably because the browser disconnected'
155
+ emit(:closed, { reason: @reason })
156
+ @disposables.dispose
157
+ super
158
+ end
159
+
160
+ private
161
+
162
+ def session
163
+ @browser.session
164
+ end
165
+
166
+ def initialize_context
167
+ # Listen for browser closure/disconnection
168
+ @browser.once(:closed) do |data|
169
+ dispose_context("User context closed: #{data[:reason]}")
170
+ end
171
+
172
+ @browser.once(:disconnected) do |data|
173
+ dispose_context("User context closed: #{data[:reason]}")
174
+ end
175
+
176
+ # Listen for browsing context creation
177
+ session.on(:'browsingContext.contextCreated') do |info|
178
+ # Only handle top-level contexts (no parent)
179
+ next if info['parent']
180
+ next if info['userContext'] != @id
181
+
182
+ browsing_context = BrowsingContext.from(
183
+ self,
184
+ nil, # parent
185
+ info['context'],
186
+ info['url'],
187
+ info['originalOpener']
188
+ )
189
+
190
+ @browsing_contexts[browsing_context.id] = browsing_context
191
+
192
+ # Listen for context closure
193
+ browsing_context.once(:closed) do
194
+ @browsing_contexts.delete(browsing_context.id)
195
+ end
196
+
197
+ emit(:browsingcontext, { browsing_context: browsing_context })
198
+ end
199
+ end
200
+
201
+ def dispose_context(reason)
202
+ @reason = reason
203
+ dispose
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end