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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/CLAUDE/README.md +158 -0
- data/CLAUDE/async_programming.md +158 -0
- data/CLAUDE/click_implementation.md +340 -0
- data/CLAUDE/core_layer_gotchas.md +136 -0
- data/CLAUDE/error_handling.md +232 -0
- data/CLAUDE/file_chooser.md +95 -0
- data/CLAUDE/frame_architecture.md +346 -0
- data/CLAUDE/javascript_evaluation.md +341 -0
- data/CLAUDE/jshandle_implementation.md +505 -0
- data/CLAUDE/keyboard_implementation.md +250 -0
- data/CLAUDE/mouse_implementation.md +140 -0
- data/CLAUDE/navigation_waiting.md +234 -0
- data/CLAUDE/porting_puppeteer.md +214 -0
- data/CLAUDE/query_handler.md +194 -0
- data/CLAUDE/rspec_pending_vs_skip.md +262 -0
- data/CLAUDE/selector_evaluation.md +198 -0
- data/CLAUDE/test_server_routes.md +263 -0
- data/CLAUDE/testing_strategy.md +236 -0
- data/CLAUDE/two_layer_architecture.md +180 -0
- data/CLAUDE/wrapped_element_click.md +247 -0
- data/CLAUDE.md +185 -0
- data/LICENSE.txt +21 -0
- data/README.md +488 -0
- data/Rakefile +21 -0
- data/lib/puppeteer/bidi/async_utils.rb +151 -0
- data/lib/puppeteer/bidi/browser.rb +285 -0
- data/lib/puppeteer/bidi/browser_context.rb +53 -0
- data/lib/puppeteer/bidi/browser_launcher.rb +240 -0
- data/lib/puppeteer/bidi/connection.rb +182 -0
- data/lib/puppeteer/bidi/core/README.md +169 -0
- data/lib/puppeteer/bidi/core/browser.rb +230 -0
- data/lib/puppeteer/bidi/core/browsing_context.rb +601 -0
- data/lib/puppeteer/bidi/core/disposable.rb +69 -0
- data/lib/puppeteer/bidi/core/errors.rb +64 -0
- data/lib/puppeteer/bidi/core/event_emitter.rb +83 -0
- data/lib/puppeteer/bidi/core/navigation.rb +128 -0
- data/lib/puppeteer/bidi/core/realm.rb +315 -0
- data/lib/puppeteer/bidi/core/request.rb +300 -0
- data/lib/puppeteer/bidi/core/session.rb +153 -0
- data/lib/puppeteer/bidi/core/user_context.rb +208 -0
- data/lib/puppeteer/bidi/core/user_prompt.rb +102 -0
- data/lib/puppeteer/bidi/core.rb +45 -0
- data/lib/puppeteer/bidi/deserializer.rb +132 -0
- data/lib/puppeteer/bidi/element_handle.rb +602 -0
- data/lib/puppeteer/bidi/errors.rb +42 -0
- data/lib/puppeteer/bidi/file_chooser.rb +52 -0
- data/lib/puppeteer/bidi/frame.rb +597 -0
- data/lib/puppeteer/bidi/http_response.rb +23 -0
- data/lib/puppeteer/bidi/injected.js +1 -0
- data/lib/puppeteer/bidi/injected_source.rb +21 -0
- data/lib/puppeteer/bidi/js_handle.rb +302 -0
- data/lib/puppeteer/bidi/keyboard.rb +265 -0
- data/lib/puppeteer/bidi/lazy_arg.rb +23 -0
- data/lib/puppeteer/bidi/mouse.rb +170 -0
- data/lib/puppeteer/bidi/page.rb +613 -0
- data/lib/puppeteer/bidi/query_handler.rb +397 -0
- data/lib/puppeteer/bidi/realm.rb +242 -0
- data/lib/puppeteer/bidi/serializer.rb +139 -0
- data/lib/puppeteer/bidi/target.rb +81 -0
- data/lib/puppeteer/bidi/task_manager.rb +44 -0
- data/lib/puppeteer/bidi/timeout_settings.rb +20 -0
- data/lib/puppeteer/bidi/transport.rb +129 -0
- data/lib/puppeteer/bidi/version.rb +7 -0
- data/lib/puppeteer/bidi/wait_task.rb +322 -0
- data/lib/puppeteer/bidi.rb +49 -0
- data/scripts/update_injected_source.rb +57 -0
- data/sig/puppeteer/bidi/browser.rbs +80 -0
- data/sig/puppeteer/bidi/element_handle.rbs +238 -0
- data/sig/puppeteer/bidi/frame.rbs +205 -0
- data/sig/puppeteer/bidi/js_handle.rbs +90 -0
- data/sig/puppeteer/bidi/page.rbs +247 -0
- data/sig/puppeteer/bidi.rbs +15 -0
- 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
|