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.
- checksums.yaml +4 -4
- data/AGENTS.md +44 -0
- data/API_COVERAGE.md +345 -0
- data/CLAUDE/porting_puppeteer.md +20 -0
- data/CLAUDE.md +2 -1
- data/DEVELOPMENT.md +14 -0
- data/README.md +47 -415
- data/development/generate_api_coverage.rb +411 -0
- data/development/puppeteer_revision.txt +1 -0
- data/lib/puppeteer/bidi/browser.rb +118 -22
- data/lib/puppeteer/bidi/browser_context.rb +185 -2
- data/lib/puppeteer/bidi/connection.rb +16 -5
- data/lib/puppeteer/bidi/cookie_utils.rb +192 -0
- data/lib/puppeteer/bidi/core/browsing_context.rb +83 -40
- data/lib/puppeteer/bidi/core/realm.rb +6 -0
- data/lib/puppeteer/bidi/core/request.rb +79 -35
- data/lib/puppeteer/bidi/core/user_context.rb +5 -3
- data/lib/puppeteer/bidi/element_handle.rb +200 -8
- data/lib/puppeteer/bidi/errors.rb +4 -0
- data/lib/puppeteer/bidi/frame.rb +115 -11
- data/lib/puppeteer/bidi/http_request.rb +577 -0
- data/lib/puppeteer/bidi/http_response.rb +161 -10
- data/lib/puppeteer/bidi/locator.rb +792 -0
- data/lib/puppeteer/bidi/page.rb +859 -7
- data/lib/puppeteer/bidi/query_handler.rb +1 -1
- data/lib/puppeteer/bidi/version.rb +1 -1
- data/lib/puppeteer/bidi.rb +39 -6
- data/sig/puppeteer/bidi/browser.rbs +53 -6
- data/sig/puppeteer/bidi/browser_context.rbs +36 -0
- data/sig/puppeteer/bidi/cookie_utils.rbs +64 -0
- data/sig/puppeteer/bidi/core/browsing_context.rbs +16 -6
- data/sig/puppeteer/bidi/core/request.rbs +14 -11
- data/sig/puppeteer/bidi/core/user_context.rbs +2 -2
- data/sig/puppeteer/bidi/element_handle.rbs +28 -0
- data/sig/puppeteer/bidi/errors.rbs +4 -0
- data/sig/puppeteer/bidi/frame.rbs +17 -0
- data/sig/puppeteer/bidi/http_request.rbs +162 -0
- data/sig/puppeteer/bidi/http_response.rbs +67 -8
- data/sig/puppeteer/bidi/locator.rbs +267 -0
- data/sig/puppeteer/bidi/page.rbs +170 -0
- data/sig/puppeteer/bidi.rbs +15 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
141
|
-
|
|
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
|
-
#
|
|
159
|
-
#
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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:
|
|
319
|
+
# @rbs return: Async::Task[untyped]
|
|
296
320
|
def delete_cookie(*cookie_filters)
|
|
297
321
|
raise BrowsingContextClosedError, @reason if closed?
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
|
585
|
+
request_id = event.dig('request', 'request')
|
|
546
586
|
next if @requests.key?(request_id)
|
|
547
587
|
|
|
548
|
-
|
|
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
|
|
561
|
-
next unless @
|
|
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
|
|
574
|
-
next unless @
|
|
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,
|