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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # Base error class for Core module
7
+ class Error < Puppeteer::Bidi::Error
8
+ end
9
+
10
+ # Raised when attempting to use a disposed resource
11
+ class DisposedError < Error
12
+ attr_reader :resource_type, :reason
13
+
14
+ def initialize(resource_type, reason)
15
+ @resource_type = resource_type
16
+ @reason = reason
17
+ super("#{resource_type} already disposed: #{reason}")
18
+ end
19
+ end
20
+
21
+ # Raised when a realm has been destroyed
22
+ class RealmDestroyedError < DisposedError
23
+ def initialize(reason)
24
+ super('Realm', reason)
25
+ end
26
+ end
27
+
28
+ # Raised when a browsing context has been closed
29
+ class BrowsingContextClosedError < DisposedError
30
+ def initialize(reason)
31
+ super('Browsing context', reason)
32
+ end
33
+ end
34
+
35
+ # Raised when a user context has been closed
36
+ class UserContextClosedError < DisposedError
37
+ def initialize(reason)
38
+ super('User context', reason)
39
+ end
40
+ end
41
+
42
+ # Raised when a user prompt has been closed
43
+ class UserPromptClosedError < DisposedError
44
+ def initialize(reason)
45
+ super('User prompt', reason)
46
+ end
47
+ end
48
+
49
+ # Raised when a session has ended
50
+ class SessionEndedError < DisposedError
51
+ def initialize(reason)
52
+ super('Session', reason)
53
+ end
54
+ end
55
+
56
+ # Raised when a browser has been disconnected
57
+ class BrowserDisconnectedError < DisposedError
58
+ def initialize(reason)
59
+ super('Browser', reason)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # EventEmitter provides event subscription and emission capabilities
7
+ # Similar to Node.js EventEmitter but simpler
8
+ class EventEmitter
9
+ def initialize
10
+ @listeners = Hash.new { |h, k| h[k] = [] }
11
+ @disposed = false
12
+ end
13
+
14
+ # Register an event listener
15
+ # @param event [Symbol, String] Event name
16
+ # @param block [Proc] Event handler
17
+ def on(event, &block)
18
+ return if @disposed
19
+ @listeners[event.to_sym] << block
20
+ end
21
+
22
+ # Register a one-time event listener
23
+ # @param event [Symbol, String] Event name
24
+ # @param block [Proc] Event handler
25
+ def once(event, &block)
26
+ return if @disposed
27
+ wrapper = lambda do |*args|
28
+ off(event, &wrapper)
29
+ block.call(*args)
30
+ end
31
+ on(event, &wrapper)
32
+ end
33
+
34
+ # Remove an event listener
35
+ # @param event [Symbol, String] Event name
36
+ # @param block [Proc] Event handler to remove (optional)
37
+ def off(event, &block)
38
+ event_sym = event.to_sym
39
+ if block
40
+ @listeners[event_sym].delete(block)
41
+ else
42
+ @listeners.delete(event_sym)
43
+ end
44
+ end
45
+
46
+ # Remove all listeners for an event or all events
47
+ # @param event [Symbol, String, nil] Event name (optional)
48
+ def remove_all_listeners(event = nil)
49
+ if event
50
+ @listeners.delete(event.to_sym)
51
+ else
52
+ @listeners.clear
53
+ end
54
+ end
55
+
56
+ # Emit an event to all registered listeners
57
+ # @param event [Symbol, String] Event name
58
+ # @param data [Object] Event data
59
+ def emit(event, data = nil)
60
+ return if @disposed
61
+ listeners = @listeners[event.to_sym].dup
62
+ listeners.each do |listener|
63
+ begin
64
+ listener.call(data)
65
+ rescue => e
66
+ warn "Error in event listener for #{event}: #{e.message}\n#{e.backtrace.join("\n")}"
67
+ end
68
+ end
69
+ end
70
+
71
+ # Dispose the event emitter
72
+ def dispose
73
+ @disposed = true
74
+ @listeners.clear
75
+ end
76
+
77
+ def disposed?
78
+ @disposed
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # Navigation represents a single navigation operation
7
+ class Navigation < EventEmitter
8
+ include Disposable::DisposableMixin
9
+
10
+ # Create a navigation instance
11
+ # @param browsing_context [BrowsingContext] The browsing context
12
+ # @return [Navigation] New navigation instance
13
+ def self.from(browsing_context)
14
+ navigation = new(browsing_context)
15
+ navigation.send(:initialize_navigation)
16
+ navigation
17
+ end
18
+
19
+ attr_reader :browsing_context, :request
20
+
21
+ def initialize(browsing_context)
22
+ super()
23
+ @browsing_context = browsing_context
24
+ @request = nil
25
+ @navigation = nil
26
+ @id = nil
27
+ @disposables = Disposable::DisposableStack.new
28
+ end
29
+
30
+ # Get the nested navigation if any
31
+ # @return [Navigation, nil] Nested navigation
32
+ def navigation
33
+ @navigation
34
+ end
35
+
36
+ protected
37
+
38
+ def perform_dispose
39
+ @disposables.dispose
40
+ super
41
+ end
42
+
43
+ private
44
+
45
+ def session
46
+ @browsing_context.user_context.browser.session
47
+ end
48
+
49
+ def initialize_navigation
50
+ # Listen for browsing context closure
51
+ @browsing_context.once(:closed) do
52
+ emit(:failed, {
53
+ url: @browsing_context.url,
54
+ timestamp: Time.now
55
+ })
56
+ dispose
57
+ end
58
+
59
+ # Listen for requests with navigation ID
60
+ @browsing_context.on(:request) do |data|
61
+ request = data[:request]
62
+ next unless request.navigation
63
+ next unless matches?(request.navigation)
64
+
65
+ @request = request
66
+ emit(:request, request)
67
+
68
+ # Listen for redirects
69
+ request.on(:redirect) do |redirect_request|
70
+ @request = redirect_request
71
+ end
72
+ end
73
+
74
+ # Listen for nested navigation
75
+ session.on('browsingContext.navigationStarted') do |info|
76
+ next unless info['context'] == @browsing_context.id
77
+ next if @navigation && !@navigation.disposed?
78
+
79
+ @navigation = Navigation.from(@browsing_context)
80
+ end
81
+
82
+ # Listen for navigation completion events
83
+ %w[browsingContext.domContentLoaded browsingContext.load].each do |event_name|
84
+ session.on(event_name) do |info|
85
+ next unless info['context'] == @browsing_context.id
86
+ next if info['navigation'].nil?
87
+ next unless matches?(info['navigation'])
88
+
89
+ dispose
90
+ end
91
+ end
92
+
93
+ # Listen for navigation end events
94
+ {
95
+ 'browsingContext.fragmentNavigated' => :fragment,
96
+ 'browsingContext.navigationFailed' => :failed,
97
+ 'browsingContext.navigationAborted' => :aborted
98
+ }.each do |event_name, emit_event|
99
+ session.on(event_name) do |info|
100
+ next unless info['context'] == @browsing_context.id
101
+ next unless matches?(info['navigation'])
102
+
103
+ emit(emit_event, {
104
+ url: info['url'],
105
+ timestamp: Time.at(info['timestamp'] / 1000.0)
106
+ })
107
+ dispose
108
+ end
109
+ end
110
+ end
111
+
112
+ def matches?(navigation_id)
113
+ # If nested navigation exists and is not disposed, this navigation doesn't match
114
+ return false if @navigation && !@navigation.disposed?
115
+
116
+ # First navigation event sets the ID
117
+ if @id.nil?
118
+ @id = navigation_id
119
+ return true
120
+ end
121
+
122
+ # Check if the navigation ID matches
123
+ @id == navigation_id
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ module Core
6
+ # Realm is the base class for script execution realms
7
+ class Realm < EventEmitter
8
+ include Disposable::DisposableMixin
9
+
10
+ attr_reader :id, :origin
11
+
12
+ def initialize(id, origin)
13
+ super()
14
+ @id = id
15
+ @origin = origin
16
+ @reason = nil
17
+ @execution_context_id = nil
18
+ @disposables = Disposable::DisposableStack.new
19
+ end
20
+
21
+ # Get the target for script execution
22
+ # @return [Hash] BiDi target descriptor
23
+ def target
24
+ { realm: @id }
25
+ end
26
+
27
+ # Disown handles (remove references)
28
+ # @param handles [Array<String>] Handle IDs to disown
29
+ def disown(handles)
30
+ raise RealmDestroyedError, @reason if disposed?
31
+ session.async_send_command('script.disown', {
32
+ target: target,
33
+ handles: handles
34
+ })
35
+ end
36
+
37
+ # Call a function in the realm
38
+ # @param function_declaration [String] Function source code
39
+ # @param await_promise [Boolean] Whether to await promise results (Note: different from returnByValue!)
40
+ # @param options [Hash] Additional options (arguments, serializationOptions, resultOwnership, etc.)
41
+ # @return [Hash] Evaluation result (with 'type', 'realm', and optionally 'result'/'exceptionDetails')
42
+ def call_function(function_declaration, await_promise, **options)
43
+ raise RealmDestroyedError, @reason if disposed?
44
+
45
+ # Note: In Puppeteer, returnByValue controls serialization, not awaitPromise
46
+ # awaitPromise controls whether to wait for promises
47
+ # For BiDi, we use 'root' ownership by default to keep handles alive
48
+ # Only use 'none' if explicitly requested
49
+ params = {
50
+ functionDeclaration: function_declaration,
51
+ awaitPromise: await_promise,
52
+ target: target,
53
+ resultOwnership: 'root',
54
+ **options
55
+ }
56
+
57
+ session.async_send_command('script.callFunction', params)
58
+ end
59
+
60
+ # Evaluate an expression in the realm
61
+ # @param expression [String] JavaScript expression
62
+ # @param await_promise [Boolean] Whether to await promise results
63
+ # @param options [Hash] Additional options (serializationOptions, resultOwnership, etc.)
64
+ # @return [Hash] Evaluation result (with 'type', 'realm', and optionally 'result'/'exceptionDetails')
65
+ def evaluate(expression, await_promise, **options)
66
+ raise RealmDestroyedError, @reason if disposed?
67
+
68
+ # Use 'root' ownership by default to keep handles alive
69
+ params = {
70
+ expression: expression,
71
+ awaitPromise: await_promise,
72
+ target: target,
73
+ resultOwnership: 'root',
74
+ **options
75
+ }
76
+
77
+ session.async_send_command('script.evaluate', params)
78
+ end
79
+
80
+ # Resolve the CDP execution context ID for this realm
81
+ # @return [Integer] Execution context ID
82
+ def resolve_execution_context_id
83
+ return @execution_context_id if @execution_context_id
84
+
85
+ # This uses a Chrome-specific extension to BiDi
86
+ result = session.connection.send_command('goog:cdp.resolveRealm', { realm: @id })
87
+ @execution_context_id = result['executionContextId']
88
+ end
89
+
90
+ protected
91
+
92
+ # Abstract method - must be implemented by subclasses
93
+ # @return [Session] The session for this realm
94
+ def session
95
+ raise NotImplementedError, 'Subclasses must implement #session'
96
+ end
97
+
98
+ def perform_dispose
99
+ @reason ||= 'Realm destroyed, probably because all associated browsing contexts closed'
100
+ emit(:destroyed, { reason: @reason })
101
+ @disposables.dispose
102
+ super
103
+ end
104
+ end
105
+
106
+ # WindowRealm represents a JavaScript realm in a window or iframe
107
+ class WindowRealm < Realm
108
+ # Create a window realm
109
+ # @param browsing_context [BrowsingContext] The browsing context
110
+ # @param sandbox [String, nil] Sandbox name
111
+ # @return [WindowRealm] New window realm
112
+ def self.from(browsing_context, sandbox = nil)
113
+ realm = new(browsing_context, sandbox)
114
+ realm.send(:initialize_realm)
115
+ realm
116
+ end
117
+
118
+ attr_reader :browsing_context, :sandbox
119
+
120
+ def initialize(browsing_context, sandbox = nil)
121
+ super('', '') # ID and origin will be set when realm is created
122
+ @browsing_context = browsing_context
123
+ @sandbox = sandbox
124
+ @workers = {}
125
+ end
126
+
127
+ # Set the environment (Frame) for this realm
128
+ # This is set by Frame when it's created
129
+ # @param frame [Frame] The frame environment
130
+ def environment=(frame)
131
+ @environment = frame
132
+ end
133
+
134
+ # Get the environment (Frame) for this realm
135
+ # @return [Frame] The frame environment
136
+ def environment
137
+ @environment
138
+ end
139
+
140
+ # Override target to use context-based target
141
+ # @return [Hash] BiDi target descriptor
142
+ def target
143
+ result = { context: @browsing_context.id }
144
+ result[:sandbox] = @sandbox if @sandbox
145
+ result
146
+ end
147
+
148
+ protected
149
+
150
+ def session
151
+ @browsing_context.user_context.browser.session
152
+ end
153
+
154
+ private
155
+
156
+ def initialize_realm
157
+ # Listen for browsing context closure
158
+ @browsing_context.once(:closed) do |data|
159
+ dispose_realm(data[:reason])
160
+ end
161
+
162
+ # Listen for realm creation (this realm)
163
+ session.on('script.realmCreated') do |info|
164
+ next unless info['type'] == 'window'
165
+ next unless info['context'] == @browsing_context.id
166
+ next unless info['sandbox'] == @sandbox
167
+
168
+ # Set the ID and origin for this realm
169
+ @id = info['realm']
170
+ @origin = info['origin']
171
+ @execution_context_id = nil
172
+ emit(:updated, self)
173
+ end
174
+
175
+ # Listen for dedicated worker creation
176
+ session.on('script.realmCreated') do |info|
177
+ next unless info['type'] == 'dedicated-worker'
178
+ next unless info['owners']&.include?(@id)
179
+
180
+ worker = DedicatedWorkerRealm.from(self, info['realm'], info['origin'])
181
+ @workers[worker.id] = worker
182
+
183
+ worker.once(:destroyed) do
184
+ @workers.delete(worker.id)
185
+ end
186
+
187
+ emit(:worker, worker)
188
+ end
189
+ end
190
+
191
+ def dispose_realm(reason)
192
+ @reason = reason
193
+ dispose
194
+ end
195
+ end
196
+
197
+ # DedicatedWorkerRealm represents a JavaScript realm in a dedicated worker
198
+ class DedicatedWorkerRealm < Realm
199
+ # Create a dedicated worker realm
200
+ # @param owner [WindowRealm, DedicatedWorkerRealm, SharedWorkerRealm] Owner realm
201
+ # @param id [String] Realm ID
202
+ # @param origin [String] Realm origin
203
+ # @return [DedicatedWorkerRealm] New dedicated worker realm
204
+ def self.from(owner, id, origin)
205
+ realm = new(owner, id, origin)
206
+ realm.send(:initialize_realm)
207
+ realm
208
+ end
209
+
210
+ attr_reader :owners
211
+
212
+ def initialize(owner, id, origin)
213
+ super(id, origin)
214
+ @owners = Set.new([owner])
215
+ @workers = {}
216
+ end
217
+
218
+ protected
219
+
220
+ def session
221
+ # Get session from any owner
222
+ @owners.first.session
223
+ end
224
+
225
+ private
226
+
227
+ def initialize_realm
228
+ # Listen for realm destruction
229
+ session.on('script.realmDestroyed') do |info|
230
+ next unless info['realm'] == @id
231
+ dispose_realm('Realm destroyed')
232
+ end
233
+
234
+ # Listen for nested dedicated worker creation
235
+ session.on('script.realmCreated') do |info|
236
+ next unless info['type'] == 'dedicated-worker'
237
+ next unless info['owners']&.include?(@id)
238
+
239
+ worker = DedicatedWorkerRealm.from(self, info['realm'], info['origin'])
240
+ @workers[worker.id] = worker
241
+
242
+ worker.once(:destroyed) do
243
+ @workers.delete(worker.id)
244
+ end
245
+
246
+ emit(:worker, worker)
247
+ end
248
+ end
249
+
250
+ def dispose_realm(reason)
251
+ @reason = reason
252
+ dispose
253
+ end
254
+ end
255
+
256
+ # SharedWorkerRealm represents a JavaScript realm in a shared worker
257
+ class SharedWorkerRealm < Realm
258
+ # Create a shared worker realm
259
+ # @param browser [Browser] Browser instance
260
+ # @param id [String] Realm ID
261
+ # @param origin [String] Realm origin
262
+ # @return [SharedWorkerRealm] New shared worker realm
263
+ def self.from(browser, id, origin)
264
+ realm = new(browser, id, origin)
265
+ realm.send(:initialize_realm)
266
+ realm
267
+ end
268
+
269
+ attr_reader :browser
270
+
271
+ def initialize(browser, id, origin)
272
+ super(id, origin)
273
+ @browser = browser
274
+ @workers = {}
275
+ end
276
+
277
+ protected
278
+
279
+ def session
280
+ @browser.session
281
+ end
282
+
283
+ private
284
+
285
+ def initialize_realm
286
+ # Listen for realm destruction
287
+ session.on('script.realmDestroyed') do |info|
288
+ next unless info['realm'] == @id
289
+ dispose_realm('Realm destroyed')
290
+ end
291
+
292
+ # Listen for dedicated worker creation
293
+ session.on('script.realmCreated') do |info|
294
+ next unless info['type'] == 'dedicated-worker'
295
+ next unless info['owners']&.include?(@id)
296
+
297
+ worker = DedicatedWorkerRealm.from(self, info['realm'], info['origin'])
298
+ @workers[worker.id] = worker
299
+
300
+ worker.once(:destroyed) do
301
+ @workers.delete(worker.id)
302
+ end
303
+
304
+ emit(:worker, worker)
305
+ end
306
+ end
307
+
308
+ def dispose_realm(reason)
309
+ @reason = reason
310
+ dispose
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end