selenium-webdriver 4.47.0 → 4.48.0

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.
Binary file
@@ -23,6 +23,10 @@ require 'selenium/webdriver/bidi/protocol/error_code'
23
23
  module Selenium
24
24
  module WebDriver
25
25
  module Error
26
+ # Raised locally when a BiDi wire payload does not match this Selenium's generated
27
+ # schema. It is not a protocol error code; the (de)serialization layer raises it directly.
28
+ class SerializationError < WebDriverError; end
29
+
26
30
  # Register each BiDi-only code as a WebDriverError subclass; shared codes keep their classic class.
27
31
  BiDi::Protocol::ErrorCode::CLASS_NAMES.each_value do |name|
28
32
  const_set(name, Class.new(WebDriverError)) unless const_defined?(name, false)
@@ -79,13 +79,13 @@ module Selenium
79
79
  construct(**attributes)
80
80
  end
81
81
 
82
- # Inbound: builds from the wire. A missing required field is omitted and warned (or
83
- # raised in strict mode, in +wire_value+); enum tokens are mapped back to symbols and an
84
- # unrecognized one raises (in +read+); an undeclared property is captured silently
85
- # (extensible) or warned and dropped (closed) — strict on shape, lenient on extras.
82
+ # Inbound: builds from the wire. A missing required field raises (in +wire_value+); enum
83
+ # tokens are mapped back to symbols and an unrecognized one raises (in +read+); an
84
+ # undeclared property is captured silently (extensible) or warned and dropped (closed)
85
+ # — strict on shape, lenient on extras.
86
86
  def from_json(json_payload)
87
87
  unless json_payload.is_a?(::Hash)
88
- raise Error::WebDriverError, "#{name} expected an object on the wire, got #{json_payload.inspect}"
88
+ raise Error::SerializationError, "#{name} expected an object on the wire, got #{json_payload.inspect}"
89
89
  end
90
90
 
91
91
  attributes = fields.to_h do |f|
@@ -173,8 +173,8 @@ module Selenium
173
173
 
174
174
  # Outbound mirror of scalar_value: a bare map key must match one of the arm's primitives.
175
175
  def check_outbound_scalar(field, value)
176
- expected = Array(field.scalar).flat_map { |primitive| PRIMITIVE_TYPES[primitive] || [] }
177
- return if expected.empty? || expected.any? { |type| value.is_a?(type) }
176
+ checks = Array(field.scalar).filter_map { |primitive| PRIMITIVE_CHECKS[primitive] }
177
+ return if checks.empty? || checks.any? { |check| check.call(value) }
178
178
 
179
179
  raise ::ArgumentError,
180
180
  "#{name}##{field.name} expected #{Array(field.scalar).join(' or ')}, got #{value.inspect}"
@@ -204,8 +204,8 @@ module Selenium
204
204
  # ArgumentError here rather than a rejection the browser reports a round-trip later. A field
205
205
  # with no primitive descriptor (enum, ref, opaque) passes; lists are skipped, as inbound does.
206
206
  def check_outbound_primitive(field, value)
207
- expected = PRIMITIVE_TYPES[field.primitive]
208
- return if expected.nil? || expected.any? { |type| value.is_a?(type) }
207
+ check = PRIMITIVE_CHECKS[field.primitive]
208
+ return if check.nil? || check.call(value)
209
209
 
210
210
  raise ::ArgumentError, "#{name}##{field.name} expected #{field.primitive}, got #{value.inspect}"
211
211
  end
@@ -214,38 +214,32 @@ module Selenium
214
214
  !UNSET.equal?(field.fixed)
215
215
  end
216
216
 
217
+ # A required field absent from the response cannot yield a valid typed object, so it raises
218
+ # rather than substitute a placeholder or represent the field as omitted; a remote end that
219
+ # lags the schema is handled by a project schema override, not by runtime tolerance.
217
220
  def wire_value(field, json_payload)
218
221
  return field.fixed if fixed?(field)
219
222
  return read(field, json_payload[field.wire_key]) if json_payload.key?(field.wire_key)
220
223
  return UNSET unless field.required
221
224
 
222
- missing_required(field)
223
- end
224
-
225
- # A required field absent from the response is tolerated as omitted (UNSET) and warned, so a
226
- # schema ahead of the browser does not block the caller; strict mode (SE_BIDI_STRICT) escalates
227
- # to an error for callers who want it. Omitted (UNSET) stays distinct from an explicit null (nil),
228
- # which matters for the required-and-nullable fields the schema flags.
229
- def missing_required(field)
230
- message = "#{name}##{field.name} is required but was missing from the response"
231
- raise Error::WebDriverError, message if Serialization.strict?
232
-
233
- WebDriver.logger.warn(message, id: :bidi_missing_required)
234
- UNSET
225
+ raise Error::SerializationError, "#{name}##{field.name} is required but was missing from the response"
235
226
  end
236
227
 
237
228
  def read(field, raw)
238
229
  if raw.nil?
239
230
  return raw if field.nullable
240
231
 
241
- raise Error::WebDriverError, "#{name}##{field.name} received null but is not nullable"
232
+ raise Error::SerializationError, "#{name}##{field.name} received null but is not nullable"
242
233
  end
243
234
  check_shape(field, raw)
244
235
  return Serialization.to_symbol("#{name}##{field.name}", raw, enum_hash(field)) if field.enum
245
236
 
246
237
  if field.ref.nil?
247
- check_primitive(field, raw) unless field.list
248
- return raw
238
+ return raw if field.list
239
+
240
+ check_primitive(field, raw)
241
+ # A whole number is exact in both types, so the declared type is held with nothing lost.
242
+ return field.primitive == 'integer' && raw.is_a?(::Float) ? raw.to_i : raw
249
243
  end
250
244
 
251
245
  read_ref(field, raw)
@@ -268,24 +262,27 @@ module Selenium
268
262
  return if field.list == raw.is_a?(::Array)
269
263
  return unless field.list || field.enum || field.ref
270
264
 
271
- raise Error::WebDriverError,
265
+ raise Error::SerializationError,
272
266
  "#{name}##{field.name} expected #{field.list ? 'a list' : 'a single value'}, got #{raw.inspect}"
273
267
  end
274
268
 
275
- # Ruby classes a checkable primitive admits. `number` is any Numeric (JSON has one
276
- # number type); `integer` requires an Integer — a browser emits `5`, not `5.0`, for an
277
- # integer (JS has no int/float split), so this rarely false-positives yet still rejects
278
- # a genuine non-integer like 1.5. A field with no primitive descriptor is left unchecked.
279
- PRIMITIVE_TYPES = {
280
- 'string' => [::String], 'boolean' => [::TrueClass, ::FalseClass],
281
- 'number' => [::Numeric], 'integer' => [::Integer]
269
+ # The check a schema primitive admits, by JSON kind rather than Ruby class: `number` is
270
+ # any Numeric (JSON has one number type), and `integer` is any whole one — a browser is
271
+ # free to send `5` or `5.0` (JS has no int/float split), while a fractional value like
272
+ # 1.5 is a real mismatch. A field with no primitive descriptor is left unchecked.
273
+ WHOLE_FLOAT = ->(value) { value.is_a?(::Float) && value.finite? && (value % 1).zero? }
274
+ PRIMITIVE_CHECKS = {
275
+ 'string' => ->(value) { value.is_a?(::String) },
276
+ 'boolean' => ->(value) { value.is_a?(::TrueClass) || value.is_a?(::FalseClass) },
277
+ 'number' => ->(value) { value.is_a?(::Numeric) },
278
+ 'integer' => ->(value) { value.is_a?(::Integer) || WHOLE_FLOAT.call(value) }
282
279
  }.freeze
283
280
 
284
281
  def check_primitive(field, raw)
285
- expected = PRIMITIVE_TYPES[field.primitive]
286
- return if expected.nil? || expected.any? { |type| raw.is_a?(type) }
282
+ check = PRIMITIVE_CHECKS[field.primitive]
283
+ return if check.nil? || check.call(raw)
287
284
 
288
- raise Error::WebDriverError, "#{name}##{field.name} expected #{field.primitive}, got #{raw.inspect}"
285
+ raise Error::SerializationError, "#{name}##{field.name} expected #{field.primitive}, got #{raw.inspect}"
289
286
  end
290
287
 
291
288
  def enum_hash(field)
@@ -314,7 +311,7 @@ module Selenium
314
311
  # malformed entry and is rejected outright.
315
312
  def read_map_entry(field, element, klass)
316
313
  unless element.is_a?(::Array) && element.size == 2
317
- raise Error::WebDriverError,
314
+ raise Error::SerializationError,
318
315
  "#{name}##{field.name} expected a [key, value] pair, got #{element.inspect}"
319
316
  end
320
317
 
@@ -326,13 +323,13 @@ module Selenium
326
323
  # A bare scalar at a scalar-tolerant union position must match one of the union's
327
324
  # scalar-arm primitives (+scalar+ is a primitive name or an array of them); a
328
325
  # wrong-typed scalar (a number where a string is expected) is a wire error, not
329
- # something to pass through. An unrecognized primitive (none in PRIMITIVE_TYPES) is
326
+ # something to pass through. An unrecognized primitive (none in PRIMITIVE_CHECKS) is
330
327
  # left unchecked, matching the lenient default elsewhere.
331
328
  def scalar_value(field, value)
332
- expected = Array(field.scalar).flat_map { |primitive| PRIMITIVE_TYPES[primitive] || [] }
333
- return value if expected.empty? || expected.any? { |type| value.is_a?(type) }
329
+ checks = Array(field.scalar).filter_map { |primitive| PRIMITIVE_CHECKS[primitive] }
330
+ return value if checks.empty? || checks.any? { |check| check.call(value) }
334
331
 
335
- raise Error::WebDriverError,
332
+ raise Error::SerializationError,
336
333
  "#{name}##{field.name} expected #{Array(field.scalar).join(' or ')}, got #{value.inspect}"
337
334
  end
338
335
 
@@ -53,19 +53,23 @@ module Selenium
53
53
  # An outbound scalar outside that set matches no arm, so it is a caller error.
54
54
  def scalar_values(*values) = @scalar_values = values
55
55
 
56
- # A non-Hash payload is a bare scalar arm (e.g. input.Origin's "viewport") with no
57
- # object to dispatch on, so it is returned unchanged unless every arm is an object
58
- # (object_only), where a non-Hash cannot match any variant and is a wire error.
56
+ # A non-Hash payload is a bare scalar arm (e.g. input.Origin's "viewport"), valid only
57
+ # as a literal the schema pins; under object_only it cannot match any variant at all.
59
58
  def from_json(json_payload)
60
59
  unless json_payload.is_a?(::Hash)
61
- return json_payload unless @object_only
62
-
63
- raise Error::WebDriverError, "#{name} expected an object on the wire, got #{json_payload.inspect}"
60
+ if @object_only
61
+ raise Error::SerializationError,
62
+ "#{name} expected an object on the wire, got #{json_payload.inspect}"
63
+ end
64
+ return json_payload if scalar_arm?(json_payload)
65
+
66
+ raise Error::SerializationError,
67
+ "#{name} received a scalar not in this Selenium's BiDi schema: #{json_payload.inspect}"
64
68
  end
65
69
 
66
70
  variant = select(json_payload)
67
71
  unless variant
68
- raise Error::WebDriverError,
72
+ raise Error::SerializationError,
69
73
  "#{name} received a variant not in this Selenium's BiDi schema: #{json_payload.inspect}"
70
74
  end
71
75
  Protocol.const_get(variant).from_json(json_payload)
@@ -21,8 +21,7 @@ module Selenium
21
21
  module WebDriver
22
22
  class BiDi
23
23
  # Wire round-trip runtime for the generated protocol layer: the value-type bases
24
- # (Record, Union), the omit sentinel (UNSET), outbound enum validation, and the
25
- # strict-inbound toggle.
24
+ # (Record, Union), the omit sentinel (UNSET), and outbound enum validation.
26
25
  #
27
26
  # @api private
28
27
  module Serialization
@@ -34,17 +33,6 @@ module Selenium
34
33
  def UNSET.inspect = 'UNSET'
35
34
  UNSET.freeze
36
35
 
37
- # Strict inbound mode. Off by default: a required field missing from a response is
38
- # tolerated as omitted and warned, so a schema ahead of the browser does not block the
39
- # caller. When SE_BIDI_STRICT is set to anything but 0/false, that same case escalates
40
- # to an error for callers who want it.
41
- #
42
- # @api private
43
- def self.strict?
44
- value = ENV.fetch('SE_BIDI_STRICT', '').strip.downcase
45
- !value.empty? && value != '0' && value != 'false'
46
- end
47
-
48
36
  # Validates an outbound enum argument: +value+ is a symbol (or list of symbols) that
49
37
  # must be a key of the enum hash (+{symbol => wire_token}+), so a bad value fails
50
38
  # locally with a clear error instead of a round-trip. Outbound only; inbound wire
@@ -79,7 +67,7 @@ module Selenium
79
67
  return value if value.nil?
80
68
  return value.map { |element| to_symbol(name, element, enum) } if value.is_a?(::Array)
81
69
 
82
- enum.key(value) || raise(Error::WebDriverError, "#{name} received an unknown value: #{value.inspect}")
70
+ enum.key(value) || raise(Error::SerializationError, "#{name} received an unknown value: #{value.inspect}")
83
71
  end
84
72
  end
85
73
  end # BiDi
@@ -21,6 +21,7 @@ require 'websocket'
21
21
 
22
22
  module Selenium
23
23
  module WebDriver
24
+ # @api private
24
25
  class WebSocketConnection
25
26
  CONNECTION_ERRORS = [
26
27
  Errno::ECONNRESET, # connection is aborted (browser process was killed)
@@ -35,6 +36,15 @@ module Selenium
35
36
 
36
37
  MAX_LOG_MESSAGE_SIZE = 9999
37
38
 
39
+ # websocket-ruby defaults to a 20MB limit and silently drops larger
40
+ # frames, which can stall the listener. CDP payloads (e.g. large data:
41
+ # URLs) can exceed that, so raise the ceiling for our connections.
42
+ # The gem only exposes the limit as process-global state, so it is
43
+ # raised (never lowered) and not restored on close - concurrent
44
+ # connections share the value. Other bindings rely on their own
45
+ # websocket clients' limits; this constant only affects websocket-ruby.
46
+ MAX_FRAME_SIZE = 100 * 1024 * 1024 # 100MB
47
+
38
48
  def initialize(url:)
39
49
  @callback_threads = ThreadGroup.new
40
50
 
@@ -46,26 +56,19 @@ module Selenium
46
56
  @session_id = nil
47
57
  @url = url
48
58
 
59
+ apply_frame_size_limit
49
60
  process_handshake
50
61
  @socket_thread = attach_socket_listener
51
62
  end
52
63
 
64
+ # Idempotent: the listener may already have initiated shutdown (see
65
+ # #frame_dropped?), so always close the socket and join threads rather
66
+ # than short-circuiting on @closing.
53
67
  def close
54
- @closing_mtx.synchronize do
55
- return if @closing
56
-
57
- @closing = true
58
- end
59
-
60
- begin
61
- socket.close
62
- rescue *CONNECTION_ERRORS => e
63
- WebDriver.logger.debug "WebSocket listener closed: #{e.class}: #{e.message}", id: :ws
64
- # already closed
65
- end
68
+ close_socket
66
69
 
67
70
  # Let threads unwind instead of calling exit
68
- @socket_thread&.join(0.5)
71
+ @socket_thread&.join(0.5) unless @socket_thread == Thread.current
69
72
  @callback_threads.list.each do |thread|
70
73
  thread.join(0.5)
71
74
  rescue StandardError => e
@@ -97,6 +100,9 @@ module Selenium
97
100
  end
98
101
 
99
102
  def send_cmd(**payload)
103
+ # IOError to match what writing to an already-closed socket raises
104
+ raise IOError, 'WebSocket connection is closed' if @closing
105
+
100
106
  id = next_id
101
107
  data = payload.merge(id: id)
102
108
  WebDriver.logger.debug "WebSocket -> #{data}"[...MAX_LOG_MESSAGE_SIZE], id: :ws
@@ -109,7 +115,11 @@ module Selenium
109
115
  raise e, "WebSocket is closed (#{e.class}: #{e.message})"
110
116
  end
111
117
 
112
- wait.until { @messages_mtx.synchronize { messages.delete(id) } }
118
+ wait.until do
119
+ raise IOError, 'WebSocket connection closed while waiting for a response' if @closing
120
+
121
+ @messages_mtx.synchronize { messages.delete(id) }
122
+ end
113
123
  end
114
124
 
115
125
  private
@@ -132,22 +142,51 @@ module Selenium
132
142
 
133
143
  incoming_frame << socket.readpartial(1024)
134
144
 
135
- while (frame = incoming_frame.next)
136
- break if @closing
137
-
138
- message = process_frame(frame)
139
- next unless message['method']
140
-
141
- @messages_mtx.synchronize { callbacks[message['method']].dup }.each do |callback|
142
- @callback_threads.add(callback_thread(message['params'], &callback))
143
- end
144
- end
145
+ process_incoming_frames
146
+ break if frame_dropped?
145
147
  end
146
148
  rescue *CONNECTION_ERRORS, WebSocket::Error => e
147
149
  WebDriver.logger.debug "WebSocket listener closed: #{e.class}: #{e.message}", id: :ws
148
150
  end
149
151
  end
150
152
 
153
+ def close_socket
154
+ @closing_mtx.synchronize { @closing = true }
155
+ socket.close
156
+ rescue *CONNECTION_ERRORS => e
157
+ WebDriver.logger.debug "WebSocket socket closed: #{e.class}: #{e.message}", id: :ws
158
+ end
159
+
160
+ def process_incoming_frames
161
+ while (frame = incoming_frame.next)
162
+ break if @closing
163
+
164
+ message = process_frame(frame)
165
+ next unless message['method']
166
+
167
+ @messages_mtx.synchronize { callbacks[message['method']].dup }.each do |callback|
168
+ @callback_threads.add(callback_thread(message['params'], &callback))
169
+ end
170
+ end
171
+ end
172
+
173
+ # True when the buffered frame could not be decoded (e.g. exceeds MAX_FRAME_SIZE).
174
+ # websocket-ruby swallows the error and keeps returning nil, so surface it and close
175
+ # the connection here instead of leaving a dead listener on an open socket.
176
+ def frame_dropped?
177
+ return false unless incoming_frame.error?
178
+
179
+ WebDriver.logger.error("WebSocket frame dropped (#{incoming_frame.error}); if payloads can legitimately " \
180
+ "exceed #{WebSocket.max_frame_size} bytes, set WebSocket.max_frame_size " \
181
+ 'to a higher value', id: :ws)
182
+ close_socket
183
+ true
184
+ end
185
+
186
+ def apply_frame_size_limit
187
+ WebSocket.max_frame_size = MAX_FRAME_SIZE if WebSocket.max_frame_size < MAX_FRAME_SIZE
188
+ end
189
+
151
190
  def incoming_frame
152
191
  @incoming_frame ||= WebSocket::Frame::Incoming::Client.new(version: ws.version)
153
192
  end
@@ -204,8 +243,7 @@ module Selenium
204
243
  end
205
244
 
206
245
  def next_id
207
- @id ||= 0
208
- @id += 1
246
+ @id = (@id || 0) + 1
209
247
  end
210
248
  end # BiDi
211
249
  end # WebDriver
@@ -19,6 +19,6 @@
19
19
 
20
20
  module Selenium
21
21
  module WebDriver
22
- VERSION = '4.47.0'
22
+ VERSION = '4.48.0'
23
23
  end # WebDriver
24
24
  end # Selenium
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: selenium-webdriver
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.47.0
4
+ version: 4.48.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Rodionov
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-08-10 00:00:00.000000000 Z
13
+ date: 2026-08-27 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: base64