puppeteer-bidi 0.0.1.beta10 → 0.0.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +44 -0
  3. data/API_COVERAGE.md +345 -0
  4. data/CLAUDE/porting_puppeteer.md +20 -0
  5. data/CLAUDE.md +2 -1
  6. data/DEVELOPMENT.md +14 -0
  7. data/README.md +47 -415
  8. data/development/generate_api_coverage.rb +411 -0
  9. data/development/puppeteer_revision.txt +1 -0
  10. data/lib/puppeteer/bidi/browser.rb +118 -22
  11. data/lib/puppeteer/bidi/browser_context.rb +185 -2
  12. data/lib/puppeteer/bidi/connection.rb +16 -5
  13. data/lib/puppeteer/bidi/cookie_utils.rb +192 -0
  14. data/lib/puppeteer/bidi/core/browsing_context.rb +83 -40
  15. data/lib/puppeteer/bidi/core/realm.rb +6 -0
  16. data/lib/puppeteer/bidi/core/request.rb +79 -35
  17. data/lib/puppeteer/bidi/core/user_context.rb +5 -3
  18. data/lib/puppeteer/bidi/element_handle.rb +200 -8
  19. data/lib/puppeteer/bidi/errors.rb +4 -0
  20. data/lib/puppeteer/bidi/frame.rb +115 -11
  21. data/lib/puppeteer/bidi/http_request.rb +577 -0
  22. data/lib/puppeteer/bidi/http_response.rb +161 -10
  23. data/lib/puppeteer/bidi/locator.rb +792 -0
  24. data/lib/puppeteer/bidi/page.rb +859 -7
  25. data/lib/puppeteer/bidi/query_handler.rb +1 -1
  26. data/lib/puppeteer/bidi/version.rb +1 -1
  27. data/lib/puppeteer/bidi.rb +39 -6
  28. data/sig/puppeteer/bidi/browser.rbs +53 -6
  29. data/sig/puppeteer/bidi/browser_context.rbs +36 -0
  30. data/sig/puppeteer/bidi/cookie_utils.rbs +64 -0
  31. data/sig/puppeteer/bidi/core/browsing_context.rbs +16 -6
  32. data/sig/puppeteer/bidi/core/request.rbs +14 -11
  33. data/sig/puppeteer/bidi/core/user_context.rbs +2 -2
  34. data/sig/puppeteer/bidi/element_handle.rbs +28 -0
  35. data/sig/puppeteer/bidi/errors.rbs +4 -0
  36. data/sig/puppeteer/bidi/frame.rbs +17 -0
  37. data/sig/puppeteer/bidi/http_request.rbs +162 -0
  38. data/sig/puppeteer/bidi/http_response.rbs +67 -8
  39. data/sig/puppeteer/bidi/locator.rbs +267 -0
  40. data/sig/puppeteer/bidi/page.rbs +170 -0
  41. data/sig/puppeteer/bidi.rbs +15 -3
  42. metadata +12 -1
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "pathname"
6
+ require "time"
7
+
8
+ ROOT = Pathname.new(__dir__).join("..").expand_path
9
+ CACHE_SCHEMA_VERSION = 1
10
+
11
+ def run_capture(*command, chdir: nil)
12
+ output = if chdir
13
+ IO.popen(command, chdir: chdir.to_s, err: %i[child out], &:read)
14
+ else
15
+ IO.popen(command, err: %i[child out], &:read)
16
+ end
17
+ status = $?
18
+ raise "Command failed: #{command.join(" ")}\n#{output}" unless status&.success?
19
+
20
+ output
21
+ end
22
+
23
+ def read_puppeteer_commit(puppeteer_dir)
24
+ run_capture("git", "rev-parse", "HEAD", chdir: puppeteer_dir.to_s).strip
25
+ rescue StandardError
26
+ "unknown"
27
+ end
28
+
29
+ def find_puppeteer_doc_paths(puppeteer_dir)
30
+ candidates = []
31
+
32
+ api_md = puppeteer_dir.join("docs/api.md")
33
+ candidates << api_md if api_md.file?
34
+
35
+ api_dir = puppeteer_dir.join("docs/api")
36
+ if api_dir.directory?
37
+ Dir.glob(api_dir.join("**/*.md")).sort.each do |path|
38
+ candidates << Pathname.new(path)
39
+ end
40
+ end
41
+
42
+ candidates.uniq
43
+ end
44
+
45
+ def extract_frontmatter_value(markdown, key)
46
+ in_frontmatter = false
47
+ markdown.each_line do |line|
48
+ stripped = line.strip
49
+ if stripped == "---"
50
+ in_frontmatter = !in_frontmatter
51
+ next
52
+ end
53
+ next unless in_frontmatter
54
+
55
+ next unless stripped.start_with?("#{key}:")
56
+
57
+ value = stripped.delete_prefix("#{key}:").strip
58
+ value = value[1..-2] if (value.start_with?('"') && value.end_with?('"')) || (value.start_with?("'") && value.end_with?("'"))
59
+ return value.strip
60
+ end
61
+ nil
62
+ end
63
+
64
+ def extract_heading_api_references(markdown, source:)
65
+ refs = []
66
+
67
+ markdown.each_line do |line|
68
+ next unless line.start_with?("#")
69
+
70
+ heading = line.sub(/\A#+\s+/, "").strip
71
+ next if heading.empty?
72
+
73
+ token = heading.split(/[\s(:—-]/, 2).first.to_s.strip
74
+ next if token.empty?
75
+ next if token.start_with?("event:", "type:", "class:", "interface:")
76
+
77
+ token = token.delete_suffix("method")
78
+ token = token.delete_suffix("property")
79
+ token = token.delete_suffix("class")
80
+ token = token.delete_suffix("()")
81
+ token = token.sub(/\Anew\s+/, "")
82
+ token = token[1..-2] if token.start_with?("`") && token.end_with?("`") && token.length >= 2
83
+
84
+ owner = nil
85
+ member = nil
86
+
87
+ if token.include?(".")
88
+ owner, member = token.split(".", 2)
89
+ elsif token.include?("#")
90
+ owner, member = token.split("#", 2)
91
+ end
92
+
93
+ next if owner.nil? || member.nil?
94
+ next if owner.empty? || member.empty?
95
+
96
+ refs << { "owner" => owner, "member" => member, "source" => source }
97
+ end
98
+
99
+ refs
100
+ end
101
+
102
+ def extract_puppeteer_api(puppeteer_dir)
103
+ doc_paths = find_puppeteer_doc_paths(puppeteer_dir)
104
+ raise "Could not find Puppeteer docs under #{puppeteer_dir}" if doc_paths.empty?
105
+
106
+ entries = []
107
+ doc_paths.each do |path|
108
+ markdown = path.read
109
+ rel = path.relative_path_from(puppeteer_dir).to_s
110
+
111
+ sidebar_label = extract_frontmatter_value(markdown, "sidebar_label")
112
+ if sidebar_label && (sidebar_label.include?(".") || sidebar_label.include?("#"))
113
+ token = sidebar_label.strip
114
+ token = token[1..-2] if token.start_with?("`") && token.end_with?("`") && token.length >= 2
115
+ owner = nil
116
+ member = nil
117
+ if token.include?(".")
118
+ owner, member = token.split(".", 2)
119
+ elsif token.include?("#")
120
+ owner, member = token.split("#", 2)
121
+ end
122
+ if owner && member && !owner.empty? && !member.empty?
123
+ entries << { "owner" => owner, "member" => member, "source" => rel }
124
+ next
125
+ end
126
+ end
127
+
128
+ entries.concat(extract_heading_api_references(markdown, source: rel))
129
+ end
130
+
131
+ dedup = {}
132
+ entries.each do |e|
133
+ key = "#{e["owner"]}.#{e["member"]}"
134
+ dedup[key] ||= e
135
+ end
136
+
137
+ dedup.values.sort_by { |e| [e["owner"].downcase, e["member"].downcase] }
138
+ end
139
+
140
+ def camel_to_snake(name)
141
+ name
142
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
143
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
144
+ .tr("-", "_")
145
+ .downcase
146
+ end
147
+
148
+ SPECIAL_MEMBER_MAPPINGS = {
149
+ "$" => "query_selector",
150
+ "$$" => "query_selector_all",
151
+ "$eval" => "eval_on_selector",
152
+ "$$eval" => "eval_on_selector_all",
153
+ "waitForSelector" => "wait_for_selector",
154
+ "waitForXPath" => "wait_for_xpath",
155
+ "waitForFunction" => "wait_for_function",
156
+ "waitForNavigation" => "wait_for_navigation",
157
+ "waitForRequest" => "wait_for_request",
158
+ "waitForResponse" => "wait_for_response",
159
+ "waitForFileChooser" => "wait_for_file_chooser",
160
+ "evaluateHandle" => "evaluate_handle",
161
+ "evaluateOnNewDocument" => "evaluate_on_new_document",
162
+ "setContent" => "set_content",
163
+ "setViewport" => "set_viewport",
164
+ "setUserAgent" => "set_user_agent",
165
+ "setExtraHTTPHeaders" => "set_extra_http_headers",
166
+ "isClosed" => "closed?"
167
+ }.freeze
168
+
169
+ def ruby_member_candidates(node_member)
170
+ mapped = SPECIAL_MEMBER_MAPPINGS[node_member]
171
+ return [mapped] if mapped
172
+
173
+ snake = camel_to_snake(node_member)
174
+ candidates = [snake]
175
+
176
+ if snake.start_with?("is_") && !snake.end_with?("?")
177
+ candidates << "#{snake.delete_prefix("is_")}?"
178
+ end
179
+
180
+ candidates.uniq
181
+ end
182
+
183
+ NODE_OWNER_ALIASES = {
184
+ "puppeteer" => "Puppeteer",
185
+ "Puppeteer" => "Puppeteer",
186
+ "PuppeteerNode" => "Puppeteer",
187
+ "browser" => "Browser",
188
+ "Browser" => "Browser",
189
+ "browsercontext" => "BrowserContext",
190
+ "browserContext" => "BrowserContext",
191
+ "BrowserContext" => "BrowserContext",
192
+ "page" => "Page",
193
+ "Page" => "Page",
194
+ "frame" => "Frame",
195
+ "Frame" => "Frame",
196
+ "elementhandle" => "ElementHandle",
197
+ "elementHandle" => "ElementHandle",
198
+ "ElementHandle" => "ElementHandle",
199
+ "jshandle" => "JSHandle",
200
+ "jsHandle" => "JSHandle",
201
+ "JSHandle" => "JSHandle",
202
+ "keyboard" => "Keyboard",
203
+ "Keyboard" => "Keyboard",
204
+ "mouse" => "Mouse",
205
+ "Mouse" => "Mouse",
206
+ "httprequest" => "HTTPRequest",
207
+ "httpRequest" => "HTTPRequest",
208
+ "HTTPRequest" => "HTTPRequest",
209
+ "httpresponse" => "HTTPResponse",
210
+ "httpResponse" => "HTTPResponse",
211
+ "HTTPResponse" => "HTTPResponse",
212
+ "filechooser" => "FileChooser",
213
+ "fileChooser" => "FileChooser",
214
+ "FileChooser" => "FileChooser",
215
+ "target" => "Target",
216
+ "Target" => "Target"
217
+ }.freeze
218
+
219
+ def canonical_node_owner(owner)
220
+ NODE_OWNER_ALIASES[owner] || NODE_OWNER_ALIASES[owner.downcase] || owner
221
+ end
222
+
223
+ RUBY_OWNER_CONSTANTS = {
224
+ "Puppeteer" => "Puppeteer::Bidi",
225
+ "Browser" => "Puppeteer::Bidi::Browser",
226
+ "BrowserContext" => "Puppeteer::Bidi::BrowserContext",
227
+ "Page" => "Puppeteer::Bidi::Page",
228
+ "Frame" => "Puppeteer::Bidi::Frame",
229
+ "ElementHandle" => "Puppeteer::Bidi::ElementHandle",
230
+ "JSHandle" => "Puppeteer::Bidi::JSHandle",
231
+ "Keyboard" => "Puppeteer::Bidi::Keyboard",
232
+ "Mouse" => "Puppeteer::Bidi::Mouse",
233
+ "HTTPRequest" => "Puppeteer::Bidi::HTTPRequest",
234
+ "HTTPResponse" => "Puppeteer::Bidi::HTTPResponse",
235
+ "FileChooser" => "Puppeteer::Bidi::FileChooser",
236
+ "Target" => "Puppeteer::Bidi::Target"
237
+ }.freeze
238
+
239
+ def safe_constantize(name)
240
+ name.split("::").inject(Object) { |mod, const_name| mod.const_get(const_name) }
241
+ rescue NameError
242
+ nil
243
+ end
244
+
245
+ def extract_ruby_public_api
246
+ $LOAD_PATH.unshift(ROOT.join("lib").to_s)
247
+ require "puppeteer/bidi"
248
+
249
+ api = {}
250
+ lib_root = ROOT.join("lib").to_s
251
+
252
+ RUBY_OWNER_CONSTANTS.each do |label, const_name|
253
+ constant = safe_constantize(const_name)
254
+ next unless constant
255
+
256
+ if constant.is_a?(Module) && !constant.is_a?(Class)
257
+ methods = constant.singleton_methods(true).map(&:to_s)
258
+ methods.select! do |method_name|
259
+ method_obj = constant.method(method_name)
260
+ owner_name = method_obj.owner.name
261
+ owner_name&.start_with?("Puppeteer::Bidi") ||
262
+ (method_obj.source_location && method_obj.source_location.first.start_with?(lib_root))
263
+ rescue NameError
264
+ false
265
+ end
266
+ api[label] = { kind: :module, const_name: const_name, methods: methods.sort.uniq }
267
+ next
268
+ end
269
+
270
+ methods = constant.public_instance_methods(true).map(&:to_s)
271
+ methods.select! do |method_name|
272
+ owner_name = constant.instance_method(method_name).owner.name
273
+ owner_name&.start_with?("Puppeteer::Bidi")
274
+ rescue NameError
275
+ false
276
+ end
277
+ api[label] = { kind: :class, const_name: const_name, methods: methods.sort.uniq }
278
+ end
279
+
280
+ api
281
+ end
282
+
283
+ def load_cache(cache_file, expected_commit:)
284
+ return nil unless cache_file.file?
285
+
286
+ data = JSON.parse(cache_file.read)
287
+ return nil unless data.is_a?(Hash)
288
+ return nil unless data["schema_version"] == CACHE_SCHEMA_VERSION
289
+ return nil unless data["puppeteer_commit"] == expected_commit
290
+
291
+ entries = data["entries"]
292
+ return nil unless entries.is_a?(Array)
293
+
294
+ entries
295
+ rescue JSON::ParserError
296
+ nil
297
+ end
298
+
299
+ def write_cache(cache_file, puppeteer_commit:, entries:)
300
+ cache_file.parent.mkpath
301
+ payload = {
302
+ "schema_version" => CACHE_SCHEMA_VERSION,
303
+ "puppeteer_commit" => puppeteer_commit,
304
+ "generated_at" => Time.now.utc.iso8601,
305
+ "entries" => entries
306
+ }
307
+ cache_file.write(JSON.pretty_generate(payload) + "\n")
308
+ end
309
+
310
+ def generate_markdown(puppeteer_commit:, entries:, ruby_api:)
311
+ supported_owners = RUBY_OWNER_CONSTANTS.keys.to_h { |k| [k, true] }
312
+ filtered = entries.select { |e| supported_owners[canonical_node_owner(e["owner"])] }
313
+ grouped = filtered.group_by { |e| canonical_node_owner(e["owner"]) }
314
+
315
+ total = 0
316
+ supported = 0
317
+
318
+ sections = []
319
+
320
+ grouped.keys.sort_by(&:downcase).each do |node_owner_label|
321
+ group = grouped.fetch(node_owner_label)
322
+ ruby_owner_const = RUBY_OWNER_CONSTANTS[node_owner_label]
323
+ ruby_owner = ruby_owner_const ? ruby_api[node_owner_label] : nil
324
+
325
+ heading = ruby_owner_const ? "#{node_owner_label} (#{ruby_owner_const})" : node_owner_label
326
+ section_lines = []
327
+ section_lines << "## #{heading}"
328
+ section_lines << ""
329
+ section_lines << "| Node.js | Ruby | Supported |"
330
+ section_lines << "| --- | --- | :---: |"
331
+
332
+ ruby_methods = ruby_owner ? ruby_owner.fetch(:methods) : []
333
+ ruby_kind = ruby_owner ? ruby_owner.fetch(:kind) : nil
334
+
335
+ group.sort_by { |e| e["member"].downcase }.each do |entry|
336
+ node_owner = entry.fetch("owner")
337
+ node_member = entry.fetch("member")
338
+ node_ref = "#{node_owner}.#{node_member}"
339
+
340
+ ruby_candidates = ruby_member_candidates(node_member)
341
+ ruby_supported_method = ruby_candidates.find { |m| ruby_methods.include?(m) }
342
+
343
+ total += 1
344
+ if ruby_supported_method
345
+ supported += 1
346
+ end
347
+
348
+ ruby_ref = "-"
349
+ if ruby_owner_const && ruby_candidates.any?
350
+ separator = ruby_kind == :module ? "." : "#"
351
+ ruby_method_name = ruby_supported_method || ruby_candidates.first
352
+ ruby_ref = "#{ruby_owner_const}#{separator}#{ruby_method_name}"
353
+ end
354
+
355
+ status = ruby_supported_method ? "✅" : "❌"
356
+ section_lines << "| `#{node_ref}` | `#{ruby_ref}` | #{status} |"
357
+ end
358
+
359
+ section_lines << ""
360
+ sections << section_lines.join("\n")
361
+ end
362
+
363
+ coverage = total.zero? ? 0.0 : (supported.to_f / total) * 100.0
364
+ lines = []
365
+ lines << "# API Coverage"
366
+ lines << ""
367
+ lines << "- Puppeteer commit: `#{puppeteer_commit}`"
368
+ lines << "- Generated by: `development/generate_api_coverage.rb`"
369
+ lines << "- Coverage: `#{supported}/#{total}` (`#{format("%.2f", coverage)}%`)"
370
+ lines << ""
371
+ lines << sections.join("\n")
372
+
373
+ lines.join("\n") + "\n"
374
+ end
375
+
376
+ options = {
377
+ puppeteer_dir: ROOT.join("development", "puppeteer").to_s,
378
+ cache_dir: ROOT.join("development", "cache").to_s,
379
+ output: ROOT.join("API_COVERAGE.md").to_s
380
+ }
381
+
382
+ OptionParser.new do |opts|
383
+ opts.banner = "Usage: bundle exec ruby development/generate_api_coverage.rb [options]"
384
+ opts.on("--puppeteer-dir=PATH", "Path to a checked out puppeteer/puppeteer repo") { |v| options[:puppeteer_dir] = v }
385
+ opts.on("--cache-dir=PATH", "Cache directory (default: development/cache)") { |v| options[:cache_dir] = v }
386
+ opts.on("--output=PATH", "Output path (default: API_COVERAGE.md)") { |v| options[:output] = v }
387
+ end.parse!
388
+
389
+ puppeteer_dir = Pathname.new(options.fetch(:puppeteer_dir)).expand_path
390
+ cache_dir = Pathname.new(options.fetch(:cache_dir)).expand_path
391
+ output_path = Pathname.new(options.fetch(:output)).expand_path
392
+
393
+ raise "Puppeteer repo not found: #{puppeteer_dir}" unless puppeteer_dir.directory?
394
+
395
+ puppeteer_commit = read_puppeteer_commit(puppeteer_dir)
396
+ cache_file = cache_dir.join("puppeteer_api.json")
397
+
398
+ entries = load_cache(cache_file, expected_commit: puppeteer_commit)
399
+ unless entries
400
+ entries = extract_puppeteer_api(puppeteer_dir)
401
+ write_cache(cache_file, puppeteer_commit: puppeteer_commit, entries: entries)
402
+ end
403
+
404
+ ruby_api = extract_ruby_public_api
405
+ output_path.write(
406
+ generate_markdown(
407
+ puppeteer_commit: puppeteer_commit,
408
+ entries: entries,
409
+ ruby_api: ruby_api
410
+ )
411
+ )
@@ -0,0 +1 @@
1
+ 7d750c25cb29764f2fb31cb90b750a8eec350199
@@ -11,32 +11,26 @@ module Puppeteer
11
11
  attr_reader :connection #: Connection
12
12
  attr_reader :process #: untyped
13
13
  attr_reader :default_browser_context #: BrowserContext
14
+ attr_reader :ws_endpoint #: String?
14
15
 
15
16
  # @rbs connection: Connection -- BiDi connection
16
17
  # @rbs launcher: BrowserLauncher? -- Browser launcher instance
18
+ # @rbs ws_endpoint: String? -- WebSocket endpoint URL
19
+ # @rbs accept_insecure_certs: bool -- Accept insecure certificates
17
20
  # @rbs return: Browser -- Browser instance
18
- def self.create(connection:, launcher: nil)
21
+ def self.create(connection:, launcher: nil, ws_endpoint: nil, accept_insecure_certs: false)
19
22
  # Create a new BiDi session
20
23
  session = Core::Session.from(
21
24
  connection: connection,
22
25
  capabilities: {
23
26
  alwaysMatch: {
24
- acceptInsecureCerts: false,
27
+ acceptInsecureCerts: accept_insecure_certs,
28
+ unhandledPromptBehavior: { default: 'ignore' },
25
29
  webSocketUrl: true,
26
30
  },
27
31
  },
28
32
  ).wait
29
33
 
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
34
  core_browser = Core::Browser.from(session).wait
41
35
  session.browser = core_browser
42
36
 
@@ -45,6 +39,7 @@ module Puppeteer
45
39
  launcher: launcher,
46
40
  core_browser: core_browser,
47
41
  session: session,
42
+ ws_endpoint: ws_endpoint,
48
43
  )
49
44
  end
50
45
 
@@ -52,13 +47,16 @@ module Puppeteer
52
47
  # @rbs launcher: BrowserLauncher? -- Browser launcher instance
53
48
  # @rbs core_browser: Core::Browser -- Core browser instance
54
49
  # @rbs session: Core::Session -- BiDi session
50
+ # @rbs ws_endpoint: String? -- WebSocket endpoint URL
55
51
  # @rbs return: void
56
- def initialize(connection:, launcher:, core_browser:, session:)
52
+ def initialize(connection:, launcher:, core_browser:, session:, ws_endpoint:)
57
53
  @connection = connection
58
54
  @launcher = launcher
59
55
  @closed = false
56
+ @disconnected = false
60
57
  @core_browser = core_browser
61
58
  @session = session
59
+ @ws_endpoint = ws_endpoint
62
60
 
63
61
  # Create default browser context
64
62
  default_user_context = @core_browser.default_user_context
@@ -74,8 +72,10 @@ module Puppeteer
74
72
  # @rbs headless: bool -- Run browser in headless mode
75
73
  # @rbs args: Array[String]? -- Additional browser arguments
76
74
  # @rbs timeout: Numeric? -- Launch timeout in seconds
75
+ # @rbs accept_insecure_certs: bool -- Accept insecure certificates
77
76
  # @rbs return: Browser -- Browser instance
78
- def self.launch(executable_path: nil, user_data_dir: nil, headless: true, args: nil, timeout: nil)
77
+ def self.launch(executable_path: nil, user_data_dir: nil, headless: true, args: nil, timeout: nil,
78
+ accept_insecure_certs: false)
79
79
  launcher = BrowserLauncher.new(
80
80
  executable_path: executable_path,
81
81
  user_data_dir: user_data_dir,
@@ -87,6 +87,7 @@ module Puppeteer
87
87
 
88
88
  # Create transport and connection
89
89
  transport = Transport.new(ws_endpoint)
90
+ ws_endpoint = transport.url
90
91
 
91
92
  # Start transport connection in background thread with Sync reactor
92
93
  # Sync is the preferred way to run async code at the top level
@@ -94,19 +95,32 @@ module Puppeteer
94
95
 
95
96
  connection = Connection.new(transport)
96
97
 
97
- browser = create(connection: connection, launcher: launcher)
98
- target = browser.wait_for_target { |target| target.type == 'page' }
98
+ browser = create(connection: connection, launcher: launcher, ws_endpoint: ws_endpoint,
99
+ accept_insecure_certs: accept_insecure_certs)
100
+ _target = browser.wait_for_target { |target| target.type == 'page' }
99
101
  browser
100
102
  end
101
103
 
102
104
  # Connect to an existing Firefox browser instance
103
105
  # @rbs ws_endpoint: String -- WebSocket endpoint URL
106
+ # @rbs timeout: Numeric? -- Connect timeout in seconds
107
+ # @rbs accept_insecure_certs: bool -- Accept insecure certificates
104
108
  # @rbs return: Browser -- Browser instance
105
- def self.connect(ws_endpoint)
109
+ def self.connect(ws_endpoint, timeout: nil, accept_insecure_certs: false)
106
110
  transport = Transport.new(ws_endpoint)
107
- AsyncUtils.async_timeout(30 * 1000, transport.connect).wait
111
+ ws_endpoint = transport.url
112
+ timeout_ms = ((timeout || 30) * 1000).to_i
113
+ AsyncUtils.async_timeout(timeout_ms, transport.connect).wait
108
114
  connection = Connection.new(transport)
109
- create(connection: connection, launcher: nil)
115
+
116
+ # Verify that this endpoint speaks WebDriver BiDi (and is ready) before creating a new session.
117
+ status = connection.async_send_command('session.status', {}, timeout: timeout_ms).wait
118
+ unless status.is_a?(Hash) && status['ready'] == true
119
+ raise Error, "WebDriver BiDi endpoint is not ready: #{status.inspect}"
120
+ end
121
+
122
+ create(connection: connection, launcher: nil, ws_endpoint: ws_endpoint,
123
+ accept_insecure_certs: accept_insecure_certs)
110
124
  end
111
125
 
112
126
  # Get BiDi session status
@@ -115,18 +129,63 @@ module Puppeteer
115
129
  @connection.async_send_command('session.status').wait
116
130
  end
117
131
 
132
+ # Get the browser's original user agent
133
+ # @rbs return: String -- User agent string
134
+ def user_agent
135
+ @session.capabilities["userAgent"]
136
+ end
137
+
118
138
  # Create a new page (Puppeteer-like API)
119
139
  # @rbs return: Page -- New page instance
120
140
  def new_page
121
141
  @default_browser_context.new_page
122
142
  end
123
143
 
144
+ # Create a new browser context
145
+ # @rbs return: BrowserContext -- New browser context
146
+ def create_browser_context
147
+ user_context = @core_browser.create_user_context.wait
148
+ browser_context_for(user_context)
149
+ end
150
+
124
151
  # Get all pages
125
152
  # @rbs return: Array[Page] -- All pages
126
153
  def pages
154
+ return [] if @closed || @disconnected
155
+
127
156
  @default_browser_context.pages
128
157
  end
129
158
 
159
+ # Get all cookies in the default browser context.
160
+ # @rbs return: Array[Hash[String, untyped]] -- Cookies
161
+ def cookies
162
+ @default_browser_context.cookies
163
+ end
164
+
165
+ # Set cookies in the default browser context.
166
+ # @rbs *cookies: Array[Hash[String, untyped]] -- Cookie data
167
+ # @rbs **cookie: untyped -- Single cookie via keyword arguments
168
+ # @rbs return: void
169
+ def set_cookie(*cookies, **cookie)
170
+ @default_browser_context.set_cookie(*cookies, **cookie)
171
+ end
172
+
173
+ # Delete cookies in the default browser context.
174
+ # @rbs *cookies: Array[Hash[String, untyped]] -- Cookies to delete
175
+ # @rbs **cookie: untyped -- Single cookie via keyword arguments
176
+ # @rbs return: void
177
+ def delete_cookie(*cookies, **cookie)
178
+ @default_browser_context.delete_cookie(*cookies, **cookie)
179
+ end
180
+
181
+ # Delete cookies matching the provided filters in the default browser context.
182
+ # @rbs *filters: Array[Hash[String, untyped]] -- Cookie filters
183
+ # @rbs **filter: untyped -- Single filter via keyword arguments
184
+ # @rbs return: void
185
+ def delete_matching_cookies(*filters, **filter)
186
+ @default_browser_context.delete_matching_cookies(*filters, **filter)
187
+ end
188
+
130
189
  # Register event handler
131
190
  # @rbs event: String | Symbol -- Event name
132
191
  # @rbs &block: (untyped) -> void -- Event handler
@@ -143,19 +202,50 @@ module Puppeteer
143
202
  @closed = true
144
203
 
145
204
  begin
146
- @connection.close
205
+ begin
206
+ @connection.async_send_command('browser.close', {}).wait
207
+ rescue StandardError => e
208
+ debug_error(e)
209
+ ensure
210
+ @connection.close
211
+ end
147
212
  rescue => e
148
- warn "Error closing connection: #{e.message}"
213
+ debug_error(e)
149
214
  end
150
215
 
151
216
  @launcher&.kill
152
217
  end
153
218
 
219
+ # Disconnect from the browser (does not close the browser process).
220
+ # @rbs return: void
221
+ def disconnect
222
+ return if @closed || @disconnected
223
+
224
+ @disconnected = true
225
+
226
+ begin
227
+ @session.end_session
228
+ rescue StandardError => e
229
+ debug_error(e)
230
+ ensure
231
+ begin
232
+ @connection.close
233
+ rescue StandardError => e
234
+ debug_error(e)
235
+ end
236
+ end
237
+ end
238
+
154
239
  # @rbs return: bool
155
240
  def closed?
156
241
  @closed
157
242
  end
158
243
 
244
+ # @rbs return: bool
245
+ def disconnected?
246
+ @disconnected
247
+ end
248
+
159
249
  # Wait until a target (top-level browsing context) satisfies the predicate.
160
250
  # @rbs timeout: Integer? -- Timeout in milliseconds (default: 30000)
161
251
  # @rbs &predicate: (BrowserTarget | PageTarget | FrameTarget) -> boolish -- Predicate evaluated against each Target
@@ -251,6 +341,12 @@ module Puppeteer
251
341
 
252
342
  private
253
343
 
344
+ def debug_error(error)
345
+ return unless ENV['DEBUG_BIDI_COMMAND']
346
+
347
+ warn(error.full_message)
348
+ end
349
+
254
350
  # @rbs &block: (BrowserTarget | PageTarget | FrameTarget) -> void -- Block to yield each target to
255
351
  # @rbs return: Enumerator[BrowserTarget | PageTarget | FrameTarget, void] -- Enumerator of targets
256
352
  def each_target(&block)
@@ -284,7 +380,7 @@ module Puppeteer
284
380
  end
285
381
 
286
382
  # @rbs user_context: Core::UserContext -- User context to get browser context for
287
- # @rbs return: BrowserContext? -- Browser context or nil
383
+ # @rbs return: BrowserContext -- Browser context
288
384
  def browser_context_for(user_context)
289
385
  return @browser_contexts[user_context.id] if @browser_contexts.key?(user_context.id)
290
386