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,285 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'async'
5
+ require 'async/promise'
6
+
7
+ module Puppeteer
8
+ module Bidi
9
+ # Browser represents a browser instance with BiDi connection
10
+ class Browser
11
+ attr_reader :connection #: Connection
12
+ attr_reader :process #: untyped
13
+ attr_reader :default_browser_context #: BrowserContext
14
+
15
+ # @rbs connection: Connection
16
+ # @rbs launcher: BrowserLauncher?
17
+ # @rbs return: Browser
18
+ def self.create(connection:, launcher: nil)
19
+ # Create a new BiDi session
20
+ session = Core::Session.from(
21
+ connection: connection,
22
+ capabilities: {
23
+ alwaysMatch: {
24
+ acceptInsecureCerts: false,
25
+ webSocketUrl: true,
26
+ },
27
+ },
28
+ ).wait
29
+
30
+ # Subscribe to BiDi modules before creating browser
31
+ subscribe_modules = %w[
32
+ browsingContext
33
+ network
34
+ log
35
+ script
36
+ input
37
+ ]
38
+ session.subscribe(subscribe_modules).wait
39
+
40
+ core_browser = Core::Browser.from(session).wait
41
+ session.browser = core_browser
42
+
43
+ new(
44
+ connection: connection,
45
+ launcher: launcher,
46
+ core_browser: core_browser,
47
+ session: session,
48
+ )
49
+ end
50
+
51
+ # @rbs connection: Connection
52
+ # @rbs launcher: BrowserLauncher?
53
+ # @rbs core_browser: Core::Browser
54
+ # @rbs session: Core::Session
55
+ # @rbs return: void
56
+ def initialize(connection:, launcher:, core_browser:, session:)
57
+ @connection = connection
58
+ @launcher = launcher
59
+ @closed = false
60
+ @core_browser = core_browser
61
+ @session = session
62
+
63
+ # Create default browser context
64
+ default_user_context = @core_browser.default_user_context
65
+ @default_browser_context = BrowserContext.new(self, default_user_context)
66
+ @browser_contexts = {
67
+ default_user_context.id => @default_browser_context
68
+ }
69
+ end
70
+
71
+ # Launch a new Firefox browser instance
72
+ # @rbs **options: untyped
73
+ # @rbs return: Browser
74
+ def self.launch(**options)
75
+ launcher = BrowserLauncher.new(
76
+ executable_path: options[:executable_path],
77
+ user_data_dir: options[:user_data_dir],
78
+ headless: options.fetch(:headless, true),
79
+ args: options.fetch(:args, [])
80
+ )
81
+
82
+ ws_endpoint = launcher.launch
83
+
84
+ # Create transport and connection
85
+ transport = Transport.new(ws_endpoint)
86
+
87
+ # Start transport connection in background thread with Sync reactor
88
+ # Sync is the preferred way to run async code at the top level
89
+ AsyncUtils.async_timeout(options.fetch(:timeout, 30) * 1000, transport.connect).wait
90
+
91
+ connection = Connection.new(transport)
92
+
93
+ browser = create(connection: connection, launcher: launcher)
94
+ target = browser.wait_for_target { |target| target.type == 'page' }
95
+ browser
96
+ end
97
+
98
+ # Get BiDi session status
99
+ # @rbs return: untyped
100
+ def status
101
+ @connection.send_command('session.status')
102
+ end
103
+
104
+ # Create a new page (Puppeteer-like API)
105
+ # @rbs return: Page
106
+ def new_page
107
+ @default_browser_context.new_page
108
+ end
109
+
110
+ # Get all pages
111
+ # @rbs return: Array[Page]
112
+ def pages
113
+ @default_browser_context.pages
114
+ end
115
+
116
+ # Register event handler
117
+ # @rbs event: String | Symbol
118
+ # @rbs &block: (untyped) -> void
119
+ # @rbs return: void
120
+ def on(event, &block)
121
+ @connection.on(event, &block)
122
+ end
123
+
124
+ # Close the browser
125
+ # @rbs return: void
126
+ def close
127
+ return if @closed
128
+
129
+ @closed = true
130
+
131
+ begin
132
+ @connection.close
133
+ rescue => e
134
+ warn "Error closing connection: #{e.message}"
135
+ end
136
+
137
+ @launcher&.kill
138
+ end
139
+
140
+ # @rbs return: bool
141
+ def closed?
142
+ @closed
143
+ end
144
+
145
+ # Wait until a target (top-level browsing context) satisfies the predicate.
146
+ # @rbs timeout: Integer?
147
+ # @rbs &predicate: (Target) -> boolish
148
+ # @rbs return: Target
149
+ def wait_for_target(timeout: nil, &predicate)
150
+ predicate ||= ->(_target) { true }
151
+ timeout_ms = timeout || 30_000
152
+ raise ArgumentError, 'timeout must be >= 0' if timeout_ms && timeout_ms.negative?
153
+
154
+ if (target = find_target(predicate))
155
+ return target
156
+ end
157
+
158
+ promise = Async::Promise.new
159
+ session_listeners = []
160
+ browser_listeners = []
161
+
162
+ cleanup = lambda do
163
+ session_listeners.each do |event, listener|
164
+ @session.off(event, &listener)
165
+ end
166
+ session_listeners.clear
167
+
168
+ browser_listeners.each do |event, listener|
169
+ @core_browser.off(event, &listener)
170
+ end
171
+ browser_listeners.clear
172
+ end
173
+
174
+ check_and_resolve = lambda do
175
+ return if promise.resolved?
176
+
177
+ begin
178
+ if (match = find_target(predicate))
179
+ promise.resolve(match)
180
+ cleanup.call
181
+ end
182
+ rescue => error
183
+ promise.reject(error) unless promise.resolved?
184
+ cleanup.call
185
+ end
186
+ end
187
+
188
+ session_listener = proc { |_data| check_and_resolve.call }
189
+ session_events = [
190
+ :'browsingContext.contextCreated',
191
+ :'browsingContext.navigationStarted',
192
+ :'browsingContext.historyUpdated',
193
+ :'browsingContext.fragmentNavigated',
194
+ :'browsingContext.domContentLoaded',
195
+ :'browsingContext.load'
196
+ ]
197
+
198
+ session_events.each do |event|
199
+ @session.on(event, &session_listener)
200
+ session_listeners << [event, session_listener]
201
+ end
202
+
203
+ browser_disconnect_listener = proc do |data|
204
+ next if promise.resolved?
205
+
206
+ reason = data[:reason] || 'Browser disconnected'
207
+ promise.reject(Core::BrowserDisconnectedError.new(reason))
208
+ cleanup.call
209
+ end
210
+
211
+ @core_browser.on(:disconnected, &browser_disconnect_listener)
212
+ browser_listeners << [:disconnected, browser_disconnect_listener]
213
+
214
+ # Re-check after listeners are set up to avoid missing fast events.
215
+ check_and_resolve.call
216
+
217
+ begin
218
+ result = if timeout_ms
219
+ AsyncUtils.async_timeout(timeout_ms, promise).wait
220
+ else
221
+ promise.wait
222
+ end
223
+ rescue Async::TimeoutError
224
+ raise TimeoutError, "Waiting for target failed: timeout #{timeout_ms}ms exceeded"
225
+ ensure
226
+ cleanup.call
227
+ end
228
+
229
+ result
230
+ end
231
+
232
+ # Wait for browser process to exit
233
+ # @rbs return: void
234
+ def wait_for_exit
235
+ @launcher&.wait
236
+ end
237
+
238
+ private
239
+
240
+ # @rbs &block: (Target) -> void
241
+ # @rbs return: Enumerator[Target, void]
242
+ def each_target(&block)
243
+ return enum_for(:each_target) unless block_given?
244
+ return unless @core_browser
245
+
246
+ yield BrowserTarget.new(self)
247
+
248
+ @core_browser.user_contexts.each do |user_context|
249
+ next if user_context.disposed?
250
+
251
+ browser_context = browser_context_for(user_context)
252
+ next unless browser_context
253
+
254
+ user_context.browsing_contexts.each do |browsing_context|
255
+ next if browsing_context.disposed?
256
+
257
+ page = browser_context.page_for(browsing_context)
258
+ yield PageTarget.new(page) if page
259
+ end
260
+ end
261
+ end
262
+
263
+ # @rbs predicate: (Target) -> boolish
264
+ # @rbs return: Target?
265
+ def find_target(predicate)
266
+ each_target do |target|
267
+ return target if predicate.call(target)
268
+ end
269
+ nil
270
+ end
271
+
272
+ # @rbs user_context: Core::UserContext
273
+ # @rbs return: BrowserContext?
274
+ def browser_context_for(user_context)
275
+ return @browser_contexts[user_context.id] if @browser_contexts.key?(user_context.id)
276
+
277
+ context = BrowserContext.new(self, user_context)
278
+ user_context.once(:closed) do
279
+ @browser_contexts.delete(user_context.id)
280
+ end
281
+ @browser_contexts[user_context.id] = context
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puppeteer
4
+ module Bidi
5
+ # BrowserContext represents an isolated browsing session
6
+ # This is a high-level wrapper around Core::UserContext
7
+ class BrowserContext
8
+ attr_reader :user_context, :browser
9
+
10
+ def initialize(browser, user_context)
11
+ @browser = browser
12
+ @user_context = user_context
13
+ @pages = {}
14
+ end
15
+
16
+ # Create a new page (tab/window)
17
+ # @return [Page] New page instance
18
+ def new_page
19
+ browsing_context = @user_context.create_browsing_context('tab')
20
+ page_for(browsing_context)
21
+ end
22
+
23
+ # Get all pages in this context
24
+ # @return [Array<Page>] All pages
25
+ def pages
26
+ @pages.values
27
+ end
28
+
29
+ def page_for(browsing_context)
30
+ @pages[browsing_context.id] ||= begin
31
+ page = Page.new(self, browsing_context)
32
+
33
+ browsing_context.once(:closed) do
34
+ @pages.delete(browsing_context.id)
35
+ end
36
+
37
+ page
38
+ end
39
+ end
40
+
41
+ # Close the browser context
42
+ def close
43
+ @user_context.close
44
+ end
45
+
46
+ # Check if context is closed
47
+ # @return [Boolean] Whether the context is closed
48
+ def closed?
49
+ @user_context.disposed?
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+ require 'async'
7
+ require 'async/http/endpoint'
8
+ require 'json'
9
+ require 'net/http'
10
+
11
+ module Puppeteer
12
+ module Bidi
13
+ # BrowserLauncher handles launching Firefox with BiDi support
14
+ class BrowserLauncher
15
+ class LaunchError < Error; end
16
+
17
+ attr_reader :executable_path, :user_data_dir
18
+
19
+ def initialize(executable_path: nil, user_data_dir: nil, headless: true, args: [])
20
+ @executable_path = executable_path || find_firefox
21
+ @user_data_dir = user_data_dir
22
+ @headless = headless
23
+ @extra_args = args
24
+ @temp_user_data_dir = nil
25
+ @process = nil
26
+ @ws_endpoint = nil
27
+ end
28
+
29
+ # Launch Firefox and return BiDi WebSocket endpoint
30
+ # @return [String] WebSocket endpoint URL
31
+ def launch
32
+ setup_user_data_dir
33
+ port = find_available_port
34
+
35
+ args = build_launch_args(port)
36
+
37
+ # Launch Firefox process
38
+ stdin, stdout, stderr, wait_thr = Open3.popen3(@executable_path, *args)
39
+ @process = wait_thr
40
+
41
+ # Close stdin as we don't need it
42
+ stdin.close
43
+
44
+ # Wait for BiDi endpoint to be available
45
+ @ws_endpoint = wait_for_ws_endpoint(port, stdout, stderr)
46
+
47
+ unless @ws_endpoint
48
+ kill
49
+ raise LaunchError, 'Failed to get BiDi WebSocket endpoint'
50
+ end
51
+
52
+ @ws_endpoint
53
+ rescue => e
54
+ kill
55
+ raise LaunchError, "Failed to launch Firefox: #{e.message}"
56
+ end
57
+
58
+ # Kill the Firefox process
59
+ def kill
60
+ if @process && @process.alive?
61
+ begin
62
+ Process.kill('TERM', @process.pid)
63
+ # Give it time to shut down gracefully
64
+ sleep(0.5)
65
+ Process.kill('KILL', @process.pid) if @process.alive?
66
+ rescue Errno::ESRCH
67
+ # Process already dead
68
+ end
69
+ end
70
+
71
+ cleanup_temp_user_data_dir
72
+ end
73
+
74
+ # Wait for process to exit
75
+ def wait
76
+ @process&.value
77
+ end
78
+
79
+ private
80
+
81
+ def find_firefox
82
+ candidates = [
83
+ ENV['FIREFOX_PATH'],
84
+ '/usr/bin/firefox-nightly',
85
+ '/usr/bin/firefox-devedition',
86
+ '/usr/bin/firefox',
87
+ '/usr/bin/firefox-esr',
88
+ '/snap/bin/firefox',
89
+ '/Applications/Firefox Nightly.app/Contents/MacOS/firefox',
90
+ '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox',
91
+ '/Applications/Firefox.app/Contents/MacOS/firefox',
92
+ ].compact
93
+
94
+ candidates.each do |path|
95
+ return path if File.executable?(path)
96
+ end
97
+
98
+ raise LaunchError, 'Could not find Firefox executable. Set FIREFOX_PATH environment variable.'
99
+ end
100
+
101
+ def setup_user_data_dir
102
+ if @user_data_dir
103
+ FileUtils.mkdir_p(@user_data_dir)
104
+ else
105
+ @temp_user_data_dir = Dir.mktmpdir('puppeteer-firefox-')
106
+ @user_data_dir = @temp_user_data_dir
107
+ end
108
+
109
+ # Create prefs.js for BiDi support
110
+ create_prefs_file
111
+ end
112
+
113
+ def create_prefs_file
114
+ profile_dir = File.join(@user_data_dir, 'profile')
115
+ FileUtils.mkdir_p(profile_dir)
116
+
117
+ prefs_file = File.join(profile_dir, 'prefs.js')
118
+ prefs_content = default_prefs.map do |key, value|
119
+ value_str = value.is_a?(String) ? "\"#{value}\"" : value
120
+ "user_pref(\"#{key}\", #{value_str});"
121
+ end.join("\n")
122
+
123
+ File.write(prefs_file, prefs_content)
124
+ end
125
+
126
+ def default_prefs
127
+ {
128
+ # Force all web content to use a single content process. TODO: remove
129
+ # this once Firefox supports mouse event dispatch from the main frame
130
+ # context. See https://bugzilla.mozilla.org/show_bug.cgi?id=1773393.
131
+ 'fission.webContentIsolationStrategy': 0,
132
+ }
133
+ end
134
+
135
+ def cleanup_temp_user_data_dir
136
+ if @temp_user_data_dir && Dir.exist?(@temp_user_data_dir)
137
+ FileUtils.rm_rf(@temp_user_data_dir)
138
+ end
139
+ end
140
+
141
+ def find_available_port
142
+ # Let Firefox choose a random port by using 0
143
+ # We'll read the actual port from the DevToolsActivePort file
144
+ 0
145
+ end
146
+
147
+ def build_launch_args(port)
148
+ args = []
149
+
150
+ if RUBY_PLATFORM =~ /darwin/
151
+ args << '--foreground'
152
+ end
153
+
154
+ # Add headless flag if needed
155
+ args << '--headless' if @headless
156
+
157
+ # Add remote debugging port
158
+ args << '--remote-debugging-port' << port.to_s
159
+
160
+ # Add profile
161
+ profile_dir = File.join(@user_data_dir, 'profile')
162
+ args << '--profile' << profile_dir
163
+
164
+ # Add user arguments
165
+ args.concat(@extra_args)
166
+
167
+ args
168
+ end
169
+
170
+ def wait_for_ws_endpoint(port, stdout, stderr, timeout: 30)
171
+ deadline = Time.now + timeout
172
+
173
+ # Start threads to read output
174
+ output_lines = []
175
+ error_lines = []
176
+ ws_endpoint = nil
177
+ mutex = Mutex.new
178
+
179
+ stdout_thread = Thread.new do
180
+ stdout.each_line do |line|
181
+ mutex.synchronize { output_lines << line }
182
+ # Check for WebDriver BiDi endpoint in stdout
183
+ if line =~ /WebDriver BiDi listening on (ws:\/\/[^\s]+)/
184
+ mutex.synchronize { ws_endpoint = $1 }
185
+ end
186
+ end
187
+ rescue => e
188
+ warn "Error reading stdout: #{e.message}"
189
+ end
190
+
191
+ stderr_thread = Thread.new do
192
+ stderr.each_line do |line|
193
+ mutex.synchronize { error_lines << line }
194
+ # Debug: print all stderr lines to help diagnose
195
+ puts "[Firefox stderr] #{line}" if ENV['DEBUG_FIREFOX']
196
+ # Firefox outputs the BiDi WebSocket endpoint to stderr
197
+ if line =~ /WebDriver BiDi listening on (ws:\/\/[^\s]+)/
198
+ mutex.synchronize { ws_endpoint = $1 }
199
+ end
200
+ end
201
+ rescue => e
202
+ warn "Error reading stderr: #{e.message}"
203
+ end
204
+
205
+ # Wait for WebSocket endpoint to be detected
206
+ loop do
207
+ if Time.now > deadline
208
+ stdout_thread.kill
209
+ stderr_thread.kill
210
+ warn "Timeout waiting for BiDi endpoint. stdout: #{output_lines.join}"
211
+ warn "stderr: #{error_lines.join}"
212
+ return nil
213
+ end
214
+
215
+ # Check if process died
216
+ unless @process.alive?
217
+ warn "Firefox process died. stderr: #{error_lines.join}"
218
+ return nil
219
+ end
220
+
221
+ # Check if we found the endpoint
222
+ mutex.synchronize do
223
+ if ws_endpoint
224
+ # Keep threads running to consume output (detach them)
225
+ stdout_thread.join(0.1)
226
+ stderr_thread.join(0.1)
227
+ return ws_endpoint
228
+ end
229
+ end
230
+
231
+ sleep(0.1)
232
+ end
233
+ ensure
234
+ # Detach the output threads (let them run in background)
235
+ stdout_thread&.join(1)
236
+ stderr_thread&.join(1)
237
+ end
238
+ end
239
+ end
240
+ end