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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/promise'
5
+
6
+ module Puppeteer
7
+ module Bidi
8
+ # Connection manages BiDi protocol communication
9
+ # Handles command sending, response waiting, and event dispatching
10
+ class Connection
11
+ class TimeoutError < Error; end
12
+ class ProtocolError < Error; end
13
+
14
+ DEFAULT_TIMEOUT = 30_000 # 30 seconds in milliseconds
15
+
16
+ def initialize(transport)
17
+ @transport = transport
18
+ @next_id = 1
19
+ @pending_commands = {}
20
+ @event_listeners = {}
21
+ @closed = false
22
+
23
+ setup_transport_handlers
24
+ end
25
+
26
+ # Send a BiDi command and wait for response
27
+ # @param method [String] BiDi method name (e.g., 'browsingContext.navigate')
28
+ # @param params [Hash] Command parameters
29
+ # @param timeout [Integer] Timeout in milliseconds
30
+ # @return [Hash] Command result
31
+ def async_send_command(method, params = {}, timeout: DEFAULT_TIMEOUT)
32
+ raise ProtocolError, 'Connection is closed' if @closed
33
+
34
+ id = next_id
35
+ command = {
36
+ id: id,
37
+ method: method,
38
+ params: params
39
+ }
40
+
41
+ # Create promise for this command
42
+ promise = Async::Promise.new
43
+
44
+ @pending_commands[id] = {
45
+ promise: promise,
46
+ method: method,
47
+ sent_at: Time.now
48
+ }
49
+
50
+ # Debug output
51
+ if ENV['DEBUG_BIDI_COMMAND']
52
+ puts "[BiDi] Request #{method}: #{command.inspect}"
53
+ end
54
+
55
+ Async do
56
+ # Send command through transport
57
+ @transport.async_send_message(command).wait
58
+
59
+ # Wait for response with timeout
60
+ begin
61
+ result = AsyncUtils.async_timeout(timeout, promise).wait
62
+
63
+ # Debug output
64
+ if ENV['DEBUG_BIDI_COMMAND']
65
+ puts "[BiDi] Response for #{method}: #{result.inspect}"
66
+ end
67
+
68
+ if result['error']
69
+ # BiDi error format: { "error": "error_type", "message": "detailed message", ... }
70
+ error_type = result['error']
71
+ error_message = result['message'] || error_type
72
+ raise ProtocolError, "BiDi error (#{method}): #{error_message}"
73
+ end
74
+
75
+ result['result']
76
+ rescue Async::TimeoutError
77
+ @pending_commands.delete(id)
78
+ raise TimeoutError, "Timeout waiting for #{method} (#{timeout}ms)"
79
+ end
80
+ end
81
+ end
82
+
83
+ # Subscribe to BiDi events
84
+ # @param event [String] Event name (e.g., 'browsingContext.navigationStarted')
85
+ # @param block [Proc] Event handler
86
+ def on(event, &block)
87
+ @event_listeners[event] ||= []
88
+ @event_listeners[event] << block
89
+ end
90
+
91
+ # Unsubscribe from BiDi events
92
+ def off(event, &block)
93
+ return unless @event_listeners[event]
94
+
95
+ if block
96
+ @event_listeners[event].delete(block)
97
+ else
98
+ @event_listeners.delete(event)
99
+ end
100
+ end
101
+
102
+ # Close the connection
103
+ def close
104
+ return if @closed
105
+
106
+ @closed = true
107
+
108
+ # Reject all pending commands
109
+ @pending_commands.each_value do |pending|
110
+ pending[:promise].reject(ProtocolError.new('Connection closed'))
111
+ end
112
+ @pending_commands.clear
113
+
114
+ @transport.close
115
+ end
116
+
117
+ def closed?
118
+ @closed
119
+ end
120
+
121
+ private
122
+
123
+ def next_id
124
+ id = @next_id
125
+ @next_id += 1
126
+ id
127
+ end
128
+
129
+ def setup_transport_handlers
130
+ @transport.on_message do |message|
131
+ handle_message(message)
132
+ end
133
+
134
+ @transport.on_close do
135
+ close
136
+ end
137
+ end
138
+
139
+ def handle_message(message)
140
+ # Response to a command (has 'id' field)
141
+ if message['id']
142
+ handle_response(message)
143
+ # Event (has 'method' but no 'id')
144
+ elsif message['method']
145
+ handle_event(message)
146
+ else
147
+ warn "Unknown BiDi message format: #{message}"
148
+ end
149
+ end
150
+
151
+ def handle_response(message)
152
+ id = message['id']
153
+ pending = @pending_commands.delete(id)
154
+
155
+ unless pending
156
+ warn "Received response for unknown command id: #{id}"
157
+ return
158
+ end
159
+
160
+ # Resolve the promise with the response
161
+ pending[:promise].resolve(message)
162
+ end
163
+
164
+ def handle_event(message)
165
+ method = message['method']
166
+ params = message['params'] || {}
167
+
168
+ listeners = @event_listeners[method]
169
+ return unless listeners
170
+
171
+ # Call all registered listeners for this event
172
+ listeners.each do |listener|
173
+ begin
174
+ listener.call(params)
175
+ rescue => e
176
+ warn "Error in event listener for #{method}: #{e.message}"
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,169 @@
1
+ # Puppeteer BiDi Core
2
+
3
+ The `core` module provides a low-level layer that sits above the WebSocket transport to provide a structured API to WebDriver BiDi's flat API. It provides object-oriented semantics around WebDriver BiDi resources and automatically carries out the correct order of events through the use of events.
4
+
5
+ This implementation is a Ruby port of Puppeteer's BiDi core layer, following the same design principles.
6
+
7
+ ## Design Principles
8
+
9
+ The following design decisions should be considered when developing or using the core layer:
10
+
11
+ ### 1. Required vs Optional Arguments
12
+
13
+ - **Required arguments** are method parameters
14
+ - **Optional arguments** are keyword arguments
15
+
16
+ This follows Ruby conventions and makes required parameters explicit.
17
+
18
+ ```ruby
19
+ # Required parameter first, optional as keyword arguments
20
+ browsing_context.navigate(url, wait: 'complete')
21
+ ```
22
+
23
+ ### 2. Session Visibility
24
+
25
+ - The session shall **never be exposed** on any public method except on Browser
26
+ - Private access to session is allowed
27
+
28
+ This prevents obfuscation of the session's origin. By only allowing it on the browser, the origin is well-defined.
29
+
30
+ ### 3. WebDriver BiDi Compliance
31
+
32
+ - `core` implements WebDriver BiDi plus its surrounding specifications
33
+ - Always follow the spec, not Puppeteer's needs
34
+ - This allows precise bug identification (spec issue vs implementation issue)
35
+
36
+ ### 4. Comprehensive but Minimal
37
+
38
+ - Implements all edges and nodes required by a feature
39
+ - Never skips intermediate nodes or composes edges
40
+ - Ensures WebDriver BiDi semantics are carried out correctly
41
+
42
+ Example: Fragment navigation must flow through Navigation to BrowsingContext, not directly from fragment navigation to BrowsingContext.
43
+
44
+ ## Architecture
45
+
46
+ The core module provides these main classes:
47
+
48
+ ### Foundation Classes
49
+
50
+ - **EventEmitter**: Event subscription and emission
51
+ - **Disposable**: Resource management and cleanup with DisposableStack
52
+
53
+ ### Protocol Classes
54
+
55
+ - **Session**: BiDi session management, wraps Connection
56
+ - **Browser**: Browser instance management
57
+ - **UserContext**: Isolated browsing contexts (similar to incognito)
58
+ - **BrowsingContext**: Individual tabs/windows/frames
59
+ - **Navigation**: Navigation tracking and lifecycle
60
+ - **Request**: Network request management
61
+ - **UserPrompt**: User prompt (alert/confirm/prompt) handling
62
+
63
+ ### Execution Context Classes
64
+
65
+ - **Realm**: Base class for JavaScript execution contexts
66
+ - **WindowRealm**: Window/iframe realms
67
+ - **DedicatedWorkerRealm**: Dedicated worker realms
68
+ - **SharedWorkerRealm**: Shared worker realms
69
+
70
+ ## Object Hierarchy
71
+
72
+ ```
73
+ Browser
74
+ ├── Session (wrapped Connection)
75
+ └── UserContext (isolated session)
76
+ └── BrowsingContext (tab/window/frame)
77
+ ├── Navigation (navigation lifecycle)
78
+ ├── Request (network requests)
79
+ ├── UserPrompt (alerts, confirms, prompts)
80
+ └── Realm (JavaScript execution)
81
+ ├── WindowRealm (main window/iframe)
82
+ ├── DedicatedWorkerRealm (web workers)
83
+ └── SharedWorkerRealm (shared workers)
84
+ ```
85
+
86
+ ## Usage Example
87
+
88
+ ```ruby
89
+ require 'puppeteer/bidi/core'
90
+
91
+ # Create a session from an existing connection
92
+ session = Puppeteer::Bidi::Core::Session.from(connection, capabilities)
93
+
94
+ # Create a browser instance
95
+ browser = Puppeteer::Bidi::Core::Browser.from(session)
96
+
97
+ # Get the default user context
98
+ context = browser.default_user_context
99
+
100
+ # Create a browsing context (tab)
101
+ browsing_context = context.create_browsing_context('tab')
102
+
103
+ # Listen for navigation events
104
+ browsing_context.on(:navigation) do |data|
105
+ navigation = data[:navigation]
106
+ puts "Navigation started to #{browsing_context.url}"
107
+ end
108
+
109
+ # Navigate to a URL
110
+ browsing_context.navigate('https://example.com', wait: 'complete')
111
+
112
+ # Evaluate JavaScript in the default realm
113
+ result = browsing_context.default_realm.evaluate('document.title', true)
114
+ puts "Page title: #{result['value']}"
115
+
116
+ # Close the browsing context
117
+ browsing_context.close
118
+ ```
119
+
120
+ ## Event Handling
121
+
122
+ All core classes extend `EventEmitter` and emit various events:
123
+
124
+ ```ruby
125
+ # Browser events
126
+ browser.on(:closed) { |data| puts "Browser closed: #{data[:reason]}" }
127
+ browser.on(:disconnected) { |data| puts "Browser disconnected: #{data[:reason]}" }
128
+
129
+ # BrowsingContext events
130
+ browsing_context.on(:navigation) { |data| puts "Navigation: #{data[:navigation]}" }
131
+ browsing_context.on(:request) { |data| puts "Request: #{data[:request].url}" }
132
+ browsing_context.on(:load) { puts "Page loaded" }
133
+ browsing_context.on(:dom_content_loaded) { puts "DOM ready" }
134
+
135
+ # Request events
136
+ request.on(:redirect) { |redirect| puts "Redirected to: #{redirect.url}" }
137
+ request.on(:success) { |response| puts "Response: #{response['status']}" }
138
+ request.on(:error) { |error| puts "Request failed: #{error}" }
139
+ ```
140
+
141
+ ## Resource Management
142
+
143
+ Core classes implement the `Disposable` pattern for proper resource cleanup:
144
+
145
+ ```ruby
146
+ # Resources are automatically disposed when parent is disposed
147
+ browser.close # Disposes all user contexts, browsing contexts, etc.
148
+
149
+ # Disposal triggers appropriate events
150
+ browsing_context.on(:closed) do |data|
151
+ puts "Context closed: #{data[:reason]}"
152
+ end
153
+
154
+ # Check disposal status
155
+ puts "Disposed: #{browsing_context.disposed?}"
156
+ ```
157
+
158
+ ## Differences from TypeScript Implementation
159
+
160
+ 1. **Ruby Conventions**: Uses snake_case instead of camelCase
161
+ 2. **Keyword Arguments**: Uses Ruby keyword arguments instead of options hashes
162
+ 3. **Symbols**: Uses symbols for event names instead of strings
163
+ 4. **No Decorators**: Decorators like `@throwIfDisposed` are implemented as method guards
164
+ 5. **Async Primitives**: Uses Ruby's Async library (Fiber-based) instead of JavaScript promises (similar to async/await)
165
+
166
+ ## References
167
+
168
+ - [WebDriver BiDi Specification](https://w3c.github.io/webdriver-bidi/)
169
+ - [Puppeteer BiDi Core (TypeScript)](https://github.com/puppeteer/puppeteer/tree/main/packages/puppeteer-core/src/bidi/core)
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # Browser represents the browser instance in the core layer
7
+ # It manages user contexts and provides browser-level operations
8
+ class Browser < EventEmitter
9
+ include Disposable::DisposableMixin
10
+
11
+ # Create a browser instance from a session
12
+ # @param session [Session] BiDi session
13
+ # @return [Browser] Browser instance
14
+ def self.from(session)
15
+ browser = new(session)
16
+ Async do
17
+ browser.send(:initialize_browser).wait
18
+ browser
19
+ end
20
+ end
21
+
22
+ attr_reader :session
23
+
24
+ def initialize(session)
25
+ super()
26
+ @session = session
27
+ @closed = false
28
+ @reason = nil
29
+ @disposables = Disposable::DisposableStack.new
30
+ @user_contexts = {}
31
+ @shared_workers = {}
32
+ end
33
+
34
+ # Check if the browser is closed
35
+ def closed?
36
+ @closed
37
+ end
38
+
39
+ # Check if the browser is disconnected
40
+ def disconnected?
41
+ !@reason.nil?
42
+ end
43
+
44
+ alias disposed? disconnected?
45
+
46
+ # Get the default user context
47
+ # @return [UserContext] Default user context
48
+ def default_user_context
49
+ @user_contexts[UserContext::DEFAULT]
50
+ end
51
+
52
+ # Get all user contexts
53
+ # @return [Array<UserContext>] All user contexts
54
+ def user_contexts
55
+ @user_contexts.values
56
+ end
57
+
58
+ # Close the browser
59
+ def close
60
+ Async do
61
+ return if @closed
62
+
63
+ begin
64
+ @session.async_send_command('browser.close', {})
65
+ ensure
66
+ dispose_browser('Browser closed', closed: true)
67
+ end
68
+ end
69
+ end
70
+
71
+ # Add a preload script to the browser
72
+ # @param function_declaration [String] JavaScript function to preload
73
+ # @param options [Hash] Preload script options
74
+ # @option options [Array<BrowsingContext>] :contexts Contexts to apply to
75
+ # @option options [String] :sandbox Sandbox name
76
+ # @return [String] Script ID
77
+ def add_preload_script(function_declaration, **options)
78
+ raise BrowserDisconnectedError, @reason if disconnected?
79
+
80
+ params = { functionDeclaration: function_declaration }
81
+ if options[:contexts]
82
+ params[:contexts] = options[:contexts].map(&:id)
83
+ end
84
+ params[:sandbox] = options[:sandbox] if options[:sandbox]
85
+
86
+ Async do
87
+ result = @session.async_send_command('script.addPreloadScript', params).wait
88
+ result['script']
89
+ end
90
+ end
91
+
92
+ # Remove a preload script
93
+ # @param script [String] Script ID
94
+ def remove_preload_script(script)
95
+ raise BrowserDisconnectedError, @reason if disconnected?
96
+ @session.async_send_command('script.removePreloadScript', { script: script })
97
+ end
98
+
99
+ # Create a new user context
100
+ # @param options [Hash] User context options
101
+ # @option options [Hash] :proxy Proxy configuration
102
+ # @return [UserContext] New user context
103
+ def create_user_context(**options)
104
+ raise BrowserDisconnectedError, @reason if disconnected?
105
+
106
+ params = {}
107
+ if options[:proxy_server]
108
+ params[:proxy] = {
109
+ proxyType: 'manual',
110
+ httpProxy: options[:proxy_server],
111
+ sslProxy: options[:proxy_server],
112
+ noProxy: options[:proxy_bypass_list]
113
+ }.compact
114
+ end
115
+
116
+ Async do
117
+ result = @session.async_send_command('browser.createUserContext', params).wait
118
+ user_context_id = result['userContext']
119
+
120
+ create_user_context_object(user_context_id)
121
+ end
122
+ end
123
+
124
+ # Remove a network intercept
125
+ # @param intercept [String] Intercept ID
126
+ def remove_intercept(intercept)
127
+ raise BrowserDisconnectedError, @reason if disconnected?
128
+ @session.async_send_command('network.removeIntercept', { intercept: intercept })
129
+ end
130
+
131
+ protected
132
+
133
+ def perform_dispose
134
+ @reason ||= 'Browser was disconnected, probably because the session ended'
135
+ emit(:closed, { reason: @reason }) if @closed
136
+ emit(:disconnected, { reason: @reason })
137
+ @disposables.dispose
138
+ super
139
+ end
140
+
141
+ private
142
+
143
+ def initialize_browser
144
+ Async do
145
+ # Listen for session end
146
+ @session.on(:ended) do |data|
147
+ dispose_browser(data[:reason])
148
+ end
149
+
150
+ # Listen for shared worker creation
151
+ @session.on('script.realmCreated') do |info|
152
+ next unless info['type'] == 'shared-worker'
153
+ # Create SharedWorkerRealm when implemented
154
+ # @shared_workers[info['realm']] = SharedWorkerRealm.from(self, info['realm'], info['origin'])
155
+ end
156
+
157
+ # Sync existing user contexts and browsing contexts
158
+ sync_user_contexts.wait
159
+ sync_browsing_contexts.wait
160
+ end
161
+ end
162
+
163
+ def sync_user_contexts
164
+ Async do
165
+ result = @session.async_send_command('browser.getUserContexts', {}).wait
166
+ user_contexts = result['userContexts']
167
+
168
+ user_contexts.each do |context_info|
169
+ create_user_context_object(context_info['userContext'])
170
+ end
171
+ end
172
+ end
173
+
174
+ def sync_browsing_contexts
175
+ Async do
176
+ # Get all browsing contexts
177
+ result = @session.async_send_command('browsingContext.getTree', {}).wait
178
+ contexts = result['contexts']
179
+
180
+ # Track context IDs for detecting created/destroyed contexts during sync
181
+ context_ids = []
182
+
183
+ # Setup temporary listener for context creation during sync
184
+ temp_listener = @session.on('browsingContext.contextCreated') do |info|
185
+ context_ids << info['context']
186
+ end
187
+
188
+ # Process all contexts (including nested ones)
189
+ process_contexts(contexts, context_ids)
190
+
191
+ # Remove temporary listener
192
+ # @session.off('browsingContext.contextCreated', &temp_listener)
193
+ end
194
+ end
195
+
196
+ def process_contexts(contexts, context_ids)
197
+ contexts.each do |info|
198
+ # Emit context created event if not already tracked
199
+ unless context_ids.include?(info['context'])
200
+ @session.emit('browsingContext.contextCreated', info)
201
+ end
202
+
203
+ # Process children recursively
204
+ process_contexts(info['children'], context_ids) if info['children']
205
+ end
206
+ end
207
+
208
+ def create_user_context_object(id)
209
+ return @user_contexts[id] if @user_contexts[id]
210
+
211
+ user_context = UserContext.create(self, id)
212
+ @user_contexts[id] = user_context
213
+
214
+ # Listen for user context closure
215
+ user_context.once(:closed) do
216
+ @user_contexts.delete(id)
217
+ end
218
+
219
+ user_context
220
+ end
221
+
222
+ def dispose_browser(reason, closed: false)
223
+ @closed = closed
224
+ @reason = reason
225
+ dispose
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end