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
@@ -1,11 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
+ require "uri"
5
+
4
6
  module Puppeteer
5
7
  module Bidi
6
8
  # BrowserContext represents an isolated browsing session
7
9
  # This is a high-level wrapper around Core::UserContext
8
10
  class BrowserContext
11
+ # Maps web permission names to protocol permission names
12
+ # Based on Puppeteer's WEB_PERMISSION_TO_PROTOCOL_PERMISSION
13
+ WEB_PERMISSION_TO_PROTOCOL_PERMISSION = {
14
+ 'accelerometer' => 'sensors',
15
+ 'ambient-light-sensor' => 'sensors',
16
+ 'background-sync' => 'backgroundSync',
17
+ 'camera' => 'videoCapture',
18
+ 'clipboard-read' => 'clipboardReadWrite',
19
+ 'clipboard-sanitized-write' => 'clipboardSanitizedWrite',
20
+ 'clipboard-write' => 'clipboardReadWrite',
21
+ 'geolocation' => 'geolocation',
22
+ 'gyroscope' => 'sensors',
23
+ 'idle-detection' => 'idleDetection',
24
+ 'keyboard-lock' => 'keyboardLock',
25
+ 'magnetometer' => 'sensors',
26
+ 'microphone' => 'audioCapture',
27
+ 'midi' => 'midi',
28
+ 'midi-sysex' => 'midiSysex',
29
+ 'notifications' => 'notifications',
30
+ 'payment-handler' => 'paymentHandler',
31
+ 'persistent-storage' => 'durableStorage',
32
+ 'pointer-lock' => 'pointerLock'
33
+ }.freeze
34
+
9
35
  attr_reader :user_context #: Core::UserContext
10
36
  attr_reader :browser #: Browser
11
37
 
@@ -28,7 +54,103 @@ module Puppeteer
28
54
  # Get all pages in this context
29
55
  # @rbs return: Array[Page] -- All pages
30
56
  def pages
31
- @pages.values
57
+ return [] if closed?
58
+
59
+ # Return pages for all currently-known top-level browsing contexts.
60
+ # Browsing contexts are synchronized from `browsingContext.getTree` during browser/session
61
+ # initialization, so this allows `Puppeteer::Bidi.connect` to expose existing pages without
62
+ # requiring an explicit enumeration via `wait_for_target`.
63
+ @user_context.browsing_contexts
64
+ .reject(&:disposed?)
65
+ .map { |browsing_context| page_for(browsing_context) }
66
+ end
67
+
68
+ # Get all cookies in this context.
69
+ # @rbs return: Array[Hash[String, untyped]] -- Cookies
70
+ def cookies
71
+ return [] if closed?
72
+
73
+ @user_context.get_cookies.wait.map do |cookie|
74
+ CookieUtils.bidi_to_puppeteer_cookie(cookie, return_composite_partition_key: true)
75
+ end
76
+ end
77
+
78
+ # Set cookies in this context.
79
+ # @rbs *cookies: Array[Hash[String, untyped]] -- Cookie data
80
+ # @rbs **cookie: untyped -- Single cookie via keyword arguments
81
+ # @rbs return: void
82
+ def set_cookie(*cookies, **cookie)
83
+ cookies = cookies.dup
84
+ cookies << cookie unless cookie.empty?
85
+
86
+ tasks = cookies.map do |raw_cookie|
87
+ normalized_cookie = CookieUtils.normalize_cookie_input(raw_cookie)
88
+ domain = normalized_cookie["domain"]
89
+ if domain.nil?
90
+ raise ArgumentError, "At least one of the url and domain needs to be specified"
91
+ end
92
+
93
+ bidi_cookie = {
94
+ "domain" => domain,
95
+ "name" => normalized_cookie["name"],
96
+ "value" => { "type" => "string", "value" => normalized_cookie["value"] },
97
+ }
98
+ bidi_cookie["path"] = normalized_cookie["path"] if normalized_cookie.key?("path")
99
+ bidi_cookie["httpOnly"] = normalized_cookie["httpOnly"] if normalized_cookie.key?("httpOnly")
100
+ bidi_cookie["secure"] = normalized_cookie["secure"] if normalized_cookie.key?("secure")
101
+ if normalized_cookie.key?("sameSite") && !normalized_cookie["sameSite"].nil?
102
+ bidi_cookie["sameSite"] = CookieUtils.convert_cookies_same_site_cdp_to_bidi(
103
+ normalized_cookie["sameSite"]
104
+ )
105
+ end
106
+ expiry = CookieUtils.convert_cookies_expiry_cdp_to_bidi(normalized_cookie["expires"])
107
+ bidi_cookie["expiry"] = expiry unless expiry.nil?
108
+ bidi_cookie.merge!(CookieUtils.cdp_specific_cookie_properties_from_puppeteer_to_bidi(
109
+ normalized_cookie,
110
+ "sameParty",
111
+ "sourceScheme",
112
+ "priority",
113
+ "url"
114
+ ))
115
+
116
+ partition_key = CookieUtils.convert_cookies_partition_key_from_puppeteer_to_bidi(
117
+ normalized_cookie["partitionKey"]
118
+ )
119
+ -> { @user_context.set_cookie(bidi_cookie, source_origin: partition_key).wait }
120
+ end
121
+
122
+ AsyncUtils.await_promise_all(*tasks) unless tasks.empty?
123
+ end
124
+
125
+ # Delete cookies in this context.
126
+ # @rbs *cookies: Array[Hash[String, untyped]] -- Cookies to delete
127
+ # @rbs **cookie: untyped -- Single cookie via keyword arguments
128
+ # @rbs return: void
129
+ def delete_cookie(*cookies, **cookie)
130
+ cookies = cookies.dup
131
+ cookies << cookie unless cookie.empty?
132
+
133
+ delete_candidates = cookies.map do |raw_cookie|
134
+ normalized_cookie = CookieUtils.normalize_cookie_input(raw_cookie)
135
+ normalized_cookie.merge("expires" => 1)
136
+ end
137
+
138
+ set_cookie(*delete_candidates)
139
+ end
140
+
141
+ # Delete cookies matching the provided filters.
142
+ # @rbs *filters: Array[Hash[String, untyped]] -- Cookie filters
143
+ # @rbs **filter: untyped -- Single cookie filter via keyword arguments
144
+ # @rbs return: void
145
+ def delete_matching_cookies(*filters, **filter)
146
+ filters = filters.dup
147
+ filters << filter unless filter.empty?
148
+
149
+ cookies_to_delete = cookies.select do |cookie|
150
+ filters.any? { |filter_entry| cookie_matches_filter?(cookie, filter_entry) }
151
+ end
152
+
153
+ delete_cookie(*cookies_to_delete)
32
154
  end
33
155
 
34
156
  # Get or create a Page for the given browsing context
@@ -46,10 +168,37 @@ module Puppeteer
46
168
  end
47
169
  end
48
170
 
171
+ # Override permissions for an origin
172
+ # @rbs origin: String -- Origin URL
173
+ # @rbs permissions: Array[String] -- Permissions to grant
174
+ # @rbs return: void
175
+ def override_permissions(origin, permissions)
176
+ # Validate all permissions are known
177
+ permissions_set = permissions.map do |permission|
178
+ protocol_permission = WEB_PERMISSION_TO_PROTOCOL_PERMISSION[permission.to_s]
179
+ raise ArgumentError, "Unknown permission: #{permission}" unless protocol_permission
180
+
181
+ permission.to_s
182
+ end.to_set
183
+
184
+ # Set each permission
185
+ WEB_PERMISSION_TO_PROTOCOL_PERMISSION.each_key do |permission|
186
+ state = permissions_set.include?(permission) ? 'granted' : 'denied'
187
+ begin
188
+ @user_context.set_permissions(origin, { name: permission }, state).wait
189
+ rescue StandardError
190
+ # Ignore errors for denied permissions (some may not be supported)
191
+ raise if permissions_set.include?(permission)
192
+ end
193
+ end
194
+ end
195
+
49
196
  # Close the browser context
50
197
  # @rbs return: void
51
198
  def close
52
- @user_context.close
199
+ return if closed?
200
+
201
+ @user_context.remove.wait
53
202
  end
54
203
 
55
204
  # Check if context is closed
@@ -57,6 +206,40 @@ module Puppeteer
57
206
  def closed?
58
207
  @user_context.disposed?
59
208
  end
209
+
210
+ private
211
+
212
+ def cookie_matches_filter?(cookie, raw_filter)
213
+ filter = CookieUtils.normalize_cookie_input(raw_filter)
214
+ return false unless filter["name"] == cookie["name"]
215
+
216
+ return true if filter.key?("domain") && filter["domain"] == cookie["domain"]
217
+ return true if filter.key?("path") && filter["path"] == cookie["path"]
218
+
219
+ if filter.key?("partitionKey") && cookie.key?("partitionKey")
220
+ cookie_partition = cookie["partitionKey"]
221
+ unless cookie_partition.is_a?(Hash)
222
+ raise Error, "Unexpected string partition key"
223
+ end
224
+
225
+ filter_partition = filter["partitionKey"]
226
+ if filter_partition.is_a?(String)
227
+ return true if filter_partition == cookie_partition["sourceOrigin"]
228
+ elsif filter_partition.is_a?(Hash)
229
+ normalized_partition = CookieUtils.normalize_cookie_input(filter_partition)
230
+ return true if normalized_partition["sourceOrigin"] == cookie_partition["sourceOrigin"]
231
+ end
232
+ end
233
+
234
+ if filter.key?("url")
235
+ url = URI.parse(filter["url"])
236
+ url_path = url.path
237
+ url_path = "/" if url_path.nil? || url_path.empty?
238
+ return true if url.host == cookie["domain"] && url_path == cookie["path"]
239
+ end
240
+
241
+ true
242
+ end
60
243
  end
61
244
  end
62
245
  end
@@ -68,14 +68,21 @@ module Puppeteer
68
68
  puts "[BiDi] Response for #{method}: #{result.inspect}"
69
69
  end
70
70
 
71
- if result['error']
72
- # BiDi error format: { "error": "error_type", "message": "detailed message", ... }
73
- error_type = result['error']
71
+ unless result.is_a?(Hash) && result.key?('type')
72
+ raise ProtocolError, "Protocol Error. Message is not in BiDi protocol format: #{result.inspect}"
73
+ end
74
+
75
+ case result['type']
76
+ when 'success'
77
+ result['result']
78
+ when 'error'
79
+ # BiDi error format: { "type": "error", "error": "...", "message": "...", ... }
80
+ error_type = result['error'] || 'unknown error'
74
81
  error_message = result['message'] || error_type
75
82
  raise ProtocolError, "BiDi error (#{method}): #{error_message}"
83
+ else
84
+ raise ProtocolError, "Protocol Error. Unexpected BiDi message type: #{result['type'].inspect}"
76
85
  end
77
-
78
- result['result']
79
86
  rescue Async::TimeoutError
80
87
  @pending_commands.delete(id)
81
88
  raise TimeoutError, "Timeout waiting for #{method} (#{timeout}ms)"
@@ -182,6 +189,10 @@ module Puppeteer
182
189
  method = message['method']
183
190
  params = message['params'] || {}
184
191
 
192
+ if ENV['DEBUG_BIDI_COMMAND']
193
+ puts "[BiDi] Event #{method}: #{params.inspect}"
194
+ end
195
+
185
196
  listeners = @event_listeners[method]
186
197
  return unless listeners
187
198
 
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Puppeteer
5
+ module Bidi
6
+ module CookieUtils
7
+ CDP_SPECIFIC_PREFIX = "goog:"
8
+
9
+ # @rbs cookie: Hash[untyped, untyped] -- Cookie with symbol or string keys
10
+ # @rbs return: Hash[String, untyped] -- Cookie with string keys
11
+ def self.normalize_cookie_input(cookie)
12
+ cookie.transform_keys(&:to_s)
13
+ end
14
+
15
+ # @rbs bidi_cookie: Hash[String, untyped] -- BiDi cookie
16
+ # @rbs return_composite_partition_key: bool -- Whether to return composite partition key
17
+ # @rbs return: Hash[String, untyped] -- Puppeteer cookie
18
+ def self.bidi_to_puppeteer_cookie(bidi_cookie, return_composite_partition_key: false)
19
+ value = bidi_cookie["value"]
20
+ value = value["value"] if value.is_a?(Hash)
21
+
22
+ expiry = bidi_cookie["expiry"]
23
+ partition_key = bidi_cookie["#{CDP_SPECIFIC_PREFIX}partitionKey"]
24
+
25
+ cookie = {
26
+ "name" => bidi_cookie["name"],
27
+ "value" => value,
28
+ "domain" => bidi_cookie["domain"],
29
+ "path" => bidi_cookie["path"],
30
+ "size" => bidi_cookie["size"],
31
+ "httpOnly" => bidi_cookie["httpOnly"],
32
+ "secure" => bidi_cookie["secure"],
33
+ "sameSite" => convert_cookies_same_site_bidi_to_cdp(bidi_cookie["sameSite"]),
34
+ "expires" => expiry.nil? ? -1 : expiry,
35
+ "session" => expiry.nil? || expiry <= 0,
36
+ }.compact
37
+
38
+ cookie.merge!(cdp_specific_cookie_properties_from_bidi(bidi_cookie, "sameParty", "sourceScheme",
39
+ "partitionKeyOpaque", "priority"))
40
+ cookie.merge!(partition_key_from_bidi(partition_key, return_composite_partition_key))
41
+
42
+ cookie
43
+ end
44
+
45
+ # @rbs same_site: String? -- BiDi SameSite value
46
+ # @rbs return: String -- Puppeteer SameSite
47
+ def self.convert_cookies_same_site_bidi_to_cdp(same_site)
48
+ case same_site
49
+ when "strict"
50
+ "Strict"
51
+ when "lax"
52
+ "Lax"
53
+ else
54
+ "None"
55
+ end
56
+ end
57
+
58
+ # @rbs same_site: String? -- Puppeteer SameSite
59
+ # @rbs return: String? -- BiDi SameSite
60
+ def self.convert_cookies_same_site_cdp_to_bidi(same_site)
61
+ case same_site
62
+ when "Strict"
63
+ "strict"
64
+ when "Lax"
65
+ "lax"
66
+ when "None"
67
+ "none"
68
+ else
69
+ nil
70
+ end
71
+ end
72
+
73
+ # @rbs expiry: Numeric? -- Cookie expiry
74
+ # @rbs return: Numeric? -- BiDi expiry
75
+ def self.convert_cookies_expiry_cdp_to_bidi(expiry)
76
+ return nil if expiry.nil? || expiry == -1
77
+
78
+ expiry
79
+ end
80
+
81
+ # @rbs partition_key: String | Hash[String, untyped] | Hash[Symbol, untyped] | nil -- Partition key
82
+ # @rbs return: String? -- BiDi partition key
83
+ def self.convert_cookies_partition_key_from_puppeteer_to_bidi(partition_key)
84
+ return partition_key if partition_key.nil? || partition_key.is_a?(String)
85
+
86
+ normalized = normalize_cookie_input(partition_key)
87
+ if normalized["hasCrossSiteAncestor"]
88
+ raise UnsupportedOperationError, "WebDriver BiDi does not support `hasCrossSiteAncestor` yet."
89
+ end
90
+
91
+ normalized["sourceOrigin"]
92
+ end
93
+
94
+ # @rbs cookie: Hash[String, untyped] -- Cookie data
95
+ # @rbs *property_names: Array[String] -- Cookie property names
96
+ # @rbs return: Hash[String, untyped] -- CDP-specific properties with goog: prefix
97
+ def self.cdp_specific_cookie_properties_from_puppeteer_to_bidi(cookie, *property_names)
98
+ property_names.each_with_object({}) do |property, result|
99
+ next unless cookie.key?(property)
100
+
101
+ value = cookie[property]
102
+ next if value.nil?
103
+
104
+ result["#{CDP_SPECIFIC_PREFIX}#{property}"] = value
105
+ end
106
+ end
107
+
108
+ # @rbs cookie: Hash[String, untyped] -- BiDi cookie data
109
+ # @rbs *property_names: Array[String] -- Cookie property names
110
+ # @rbs return: Hash[String, untyped] -- CDP-specific properties
111
+ def self.cdp_specific_cookie_properties_from_bidi(cookie, *property_names)
112
+ property_names.each_with_object({}) do |property, result|
113
+ key = "#{CDP_SPECIFIC_PREFIX}#{property}"
114
+ next unless cookie.key?(key)
115
+
116
+ result[property] = cookie[key]
117
+ end
118
+ end
119
+
120
+ # @rbs cookie: Hash[String, untyped] -- Puppeteer cookie
121
+ # @rbs normalized_url: URI::Generic -- URL to match
122
+ # @rbs return: bool -- Whether cookie matches URL
123
+ def self.test_url_match_cookie(cookie, normalized_url)
124
+ return false unless test_url_match_cookie_hostname(cookie, normalized_url)
125
+
126
+ test_url_match_cookie_path(cookie, normalized_url)
127
+ end
128
+
129
+ # @rbs cookie: Hash[String, untyped] -- Puppeteer cookie
130
+ # @rbs normalized_url: URI::Generic -- URL to match
131
+ # @rbs return: bool -- Whether hostname matches
132
+ def self.test_url_match_cookie_hostname(cookie, normalized_url)
133
+ url_hostname = normalized_url.host
134
+ return false if url_hostname.nil?
135
+
136
+ cookie_domain = cookie.fetch("domain", "").downcase
137
+ url_hostname = url_hostname.downcase
138
+
139
+ return true if cookie_domain == url_hostname
140
+
141
+ cookie_domain.start_with?(".") && url_hostname.end_with?(cookie_domain)
142
+ end
143
+
144
+ # @rbs cookie: Hash[String, untyped] -- Puppeteer cookie
145
+ # @rbs normalized_url: URI::Generic -- URL to match
146
+ # @rbs return: bool -- Whether path matches
147
+ def self.test_url_match_cookie_path(cookie, normalized_url)
148
+ uri_path = normalized_url.path
149
+ uri_path = "/" if uri_path.nil? || uri_path.empty?
150
+
151
+ cookie_path = cookie["path"] || "/"
152
+
153
+ return true if uri_path == cookie_path
154
+
155
+ if uri_path.start_with?(cookie_path)
156
+ return true if cookie_path.end_with?("/")
157
+ return true if uri_path[cookie_path.length] == "/"
158
+ end
159
+
160
+ false
161
+ end
162
+
163
+ # @rbs partition_key: untyped -- BiDi partition key
164
+ # @rbs return_composite_partition_key: bool -- Whether to return composite partition key
165
+ # @rbs return: Hash[String, untyped] -- Partition key info
166
+ def self.partition_key_from_bidi(partition_key, return_composite_partition_key)
167
+ return {} if partition_key.nil?
168
+
169
+ if partition_key.is_a?(String)
170
+ return { "partitionKey" => partition_key }
171
+ end
172
+
173
+ return {} unless partition_key.is_a?(Hash)
174
+
175
+ normalized = normalize_cookie_input(partition_key)
176
+ top_level_site = normalized["topLevelSite"]
177
+ has_cross_site_ancestor = normalized["hasCrossSiteAncestor"]
178
+
179
+ if return_composite_partition_key
180
+ return {
181
+ "partitionKey" => {
182
+ "sourceOrigin" => top_level_site,
183
+ "hasCrossSiteAncestor" => has_cross_site_ancestor.nil? ? false : has_cross_site_ancestor,
184
+ },
185
+ }
186
+ end
187
+
188
+ { "partitionKey" => top_level_site }
189
+ end
190
+ end
191
+ end
192
+ end
@@ -35,6 +35,7 @@ module Puppeteer
35
35
  @children = {}
36
36
  @realms = {}
37
37
  @requests = {}
38
+ @inflight_request_ids = {}
38
39
  @navigation = nil
39
40
  @emulation_state = { javascript_enabled: true }
40
41
  @inflight_requests = 0
@@ -136,32 +137,32 @@ module Puppeteer
136
137
  raise BrowsingContextClosedError, @reason if closed?
137
138
 
138
139
  Async do
139
- # Close all children first
140
- children.each do |child|
141
- begin
142
- child.close(prompt_unload: prompt_unload).wait
143
- rescue BrowsingContextClosedError
144
- # Child already closed (e.g., iframe removed mid-operation)
145
- end
140
+ # Close all children first (matches Puppeteer's Promise.all pattern)
141
+ child_close_tasks = children.map do |child|
142
+ -> { child.close(prompt_unload: prompt_unload).wait rescue BrowsingContextClosedError }
146
143
  end
144
+ AsyncUtils.promise_all(*child_close_tasks).wait unless child_close_tasks.empty?
145
+
146
+ # Ensure page.closed? is true and that the context has been removed
147
+ # from parent registries once this call returns.
148
+ # Register listener BEFORE sending close command to avoid race condition.
149
+ closed_promise = Async::Promise.new
150
+ closed_listener = ->(_) { closed_promise.resolve(nil) }
151
+ on(:closed, &closed_listener)
147
152
 
148
- # Send close command
149
- # Note: For non-top-level contexts (iframes), this may fail with
150
- # "Browsing context ... is not top-level" error, which is expected
151
- # because parent closure automatically closes children in BiDi protocol
152
153
  begin
153
154
  session.async_send_command('browsingContext.close', {
154
155
  context: @id,
155
156
  promptUnload: prompt_unload
156
- })
157
+ }).wait
158
+ # Wait for :closed event to ensure state is updated
159
+ closed_promise.wait
157
160
  rescue Connection::ProtocolError => e
158
- # Ignore "not top-level" errors for iframe contexts
159
- # This happens when parent context closes and BiDi auto-closes children
160
- # The error message is in format: "BiDi error (browsingContext.close): Browsing context with id ... is not top-level"
161
- if ENV['DEBUG_BIDI_COMMAND']
162
- puts "[BiDi] Close error for context #{@id}: #{e.message.inspect}"
163
- end
161
+ # "is not top-level" error occurs for iframes - they are closed
162
+ # automatically when parent closes, so we don't need to wait
164
163
  raise unless e.message.include?('is not top-level')
164
+ ensure
165
+ off(:closed, &closed_listener)
165
166
  end
166
167
  end
167
168
  end
@@ -255,16 +256,37 @@ module Puppeteer
255
256
 
256
257
  # Add network intercept
257
258
  # @rbs **options: untyped -- Intercept options
258
- # @rbs return: String -- Intercept ID
259
+ # @rbs return: Async::Task[String] -- Intercept ID
259
260
  def add_intercept(**options)
260
261
  raise BrowsingContextClosedError, @reason if closed?
261
- result = session.async_send_command('network.addIntercept', options.merge(contexts: [@id]))
262
- result['intercept']
262
+ Async do
263
+ result = session.async_send_command('network.addIntercept', options.merge(contexts: [@id])).wait
264
+ result['intercept']
265
+ end
266
+ end
267
+
268
+ # Set extra HTTP headers for this context
269
+ # @rbs headers: Hash[String, String] -- Extra headers
270
+ # @rbs return: Async::Task[untyped]
271
+ def set_extra_http_headers(headers)
272
+ raise BrowsingContextClosedError, @reason if closed?
273
+
274
+ normalized = headers.map do |key, value|
275
+ {
276
+ name: key.to_s.downcase,
277
+ value: { type: 'string', value: value.to_s }
278
+ }
279
+ end
280
+
281
+ session.async_send_command('network.setExtraHeaders', {
282
+ contexts: [@id],
283
+ headers: normalized
284
+ })
263
285
  end
264
286
 
265
287
  # Get cookies
266
288
  # @rbs **options: untyped -- Cookie filter options
267
- # @rbs return: Array[Hash[String, untyped]] -- Cookies
289
+ # @rbs return: Async::Task[Array[Hash[String, untyped]]] -- Cookies
268
290
  def get_cookies(**options)
269
291
  raise BrowsingContextClosedError, @reason if closed?
270
292
  params = options.dup
@@ -272,8 +294,10 @@ module Puppeteer
272
294
  type: 'context',
273
295
  context: @id
274
296
  }
275
- result = session.async_send_command('storage.getCookies', params)
276
- result['cookies']
297
+ Async do
298
+ result = session.async_send_command('storage.getCookies', params).wait
299
+ result['cookies']
300
+ end
277
301
  end
278
302
 
279
303
  # Set a cookie
@@ -292,17 +316,21 @@ module Puppeteer
292
316
 
293
317
  # Delete cookies
294
318
  # @rbs *cookie_filters: Hash[String, untyped] -- Cookie filters
295
- # @rbs return: void
319
+ # @rbs return: Async::Task[untyped]
296
320
  def delete_cookie(*cookie_filters)
297
321
  raise BrowsingContextClosedError, @reason if closed?
298
- cookie_filters.each do |filter|
299
- session.async_send_command('storage.deleteCookies', {
300
- filter: filter,
301
- partition: {
302
- type: 'context',
303
- context: @id
304
- }
305
- })
322
+ Async do
323
+ tasks = cookie_filters.map do |filter|
324
+ session.async_send_command('storage.deleteCookies', {
325
+ filter: filter,
326
+ partition: {
327
+ type: 'context',
328
+ context: @id
329
+ }
330
+ })
331
+ end
332
+
333
+ AsyncUtils.promise_all(*tasks).wait unless tasks.empty?
306
334
  end
307
335
  end
308
336
 
@@ -311,7 +339,7 @@ module Puppeteer
311
339
  # @rbs return: Async::Task[untyped]
312
340
  def set_geolocation_override(**options)
313
341
  raise BrowsingContextClosedError, @reason if closed?
314
- raise 'Missing coordinates' unless options[:coordinates]
342
+ raise 'Missing coordinates' unless options.key?(:coordinates)
315
343
 
316
344
  session.async_send_command('emulation.setGeolocationOverride', {
317
345
  coordinates: options[:coordinates],
@@ -319,6 +347,18 @@ module Puppeteer
319
347
  })
320
348
  end
321
349
 
350
+ # Set user agent override
351
+ # @rbs user_agent: String? -- User agent string or nil to restore original
352
+ # @rbs return: Async::Task[void]
353
+ def set_user_agent(user_agent)
354
+ raise BrowsingContextClosedError, @reason if closed?
355
+
356
+ session.async_send_command("emulation.setUserAgentOverride", {
357
+ userAgent: user_agent,
358
+ contexts: [@id]
359
+ })
360
+ end
361
+
322
362
  # Set timezone override
323
363
  # @rbs timezone_id: String? -- Timezone ID
324
364
  # @rbs return: Async::Task[untyped]
@@ -542,13 +582,16 @@ module Puppeteer
542
582
  session.on('network.beforeRequestSent') do |event|
543
583
  next unless event['context'] == @id
544
584
 
545
- request_id = event['request']['request']
585
+ request_id = event.dig('request', 'request')
546
586
  next if @requests.key?(request_id)
547
587
 
548
- @requests[request_id] = true
588
+ request = Request.from(self, event)
589
+ @requests[request_id] = request
590
+ emit(:request, { request: request })
549
591
 
550
592
  # Increment inflight requests counter
551
593
  @inflight_mutex.synchronize do
594
+ @inflight_request_ids[request_id] = true
552
595
  @inflight_requests += 1
553
596
  emit(:inflight_changed, { inflight: @inflight_requests })
554
597
  end
@@ -557,8 +600,8 @@ module Puppeteer
557
600
  session.on('network.responseCompleted') do |event|
558
601
  next unless event['context'] == @id
559
602
 
560
- request_id = event['request']['request']
561
- next unless @requests.delete(request_id)
603
+ request_id = event.dig('request', 'request')
604
+ next unless @inflight_request_ids.delete(request_id)
562
605
 
563
606
  # Decrement inflight requests counter
564
607
  @inflight_mutex.synchronize do
@@ -570,8 +613,8 @@ module Puppeteer
570
613
  session.on('network.fetchError') do |event|
571
614
  next unless event['context'] == @id
572
615
 
573
- request_id = event['request']['request']
574
- next unless @requests.delete(request_id)
616
+ request_id = event.dig('request', 'request')
617
+ next unless @inflight_request_ids.delete(request_id)
575
618
 
576
619
  # Decrement inflight requests counter
577
620
  @inflight_mutex.synchronize do
@@ -44,6 +44,9 @@ module Puppeteer
44
44
  def call_function(function_declaration, await_promise, **options)
45
45
  raise RealmDestroyedError, @reason if disposed?
46
46
 
47
+ options = options.dup
48
+ options[:userActivation] = true unless options.key?(:userActivation) || options.key?("userActivation")
49
+
47
50
  # Note: In Puppeteer, returnByValue controls serialization, not awaitPromise
48
51
  # awaitPromise controls whether to wait for promises
49
52
  # For BiDi, we use 'root' ownership by default to keep handles alive
@@ -67,6 +70,9 @@ module Puppeteer
67
70
  def evaluate(expression, await_promise, **options)
68
71
  raise RealmDestroyedError, @reason if disposed?
69
72
 
73
+ options = options.dup
74
+ options[:userActivation] = true unless options.key?(:userActivation) || options.key?("userActivation")
75
+
70
76
  # Use 'root' ownership by default to keep handles alive
71
77
  params = {
72
78
  expression: expression,