selenium-webdriver 4.46.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES +39 -0
  3. data/bin/linux/selenium-manager +0 -0
  4. data/bin/macos/selenium-manager +0 -0
  5. data/bin/selenium-manager-THIRD-PARTY-NOTICES.txt +5940 -0
  6. data/bin/selenium-manager.cdx.json +11708 -0
  7. data/bin/windows/selenium-manager.exe +0 -0
  8. data/lib/selenium/webdriver/bidi/error.rb +48 -0
  9. data/lib/selenium/webdriver/bidi/protocol/bluetooth.rb +53 -23
  10. data/lib/selenium/webdriver/bidi/protocol/browser.rb +43 -15
  11. data/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +172 -68
  12. data/lib/selenium/webdriver/bidi/protocol/domain.rb +8 -2
  13. data/lib/selenium/webdriver/bidi/protocol/emulation.rb +123 -9
  14. data/lib/selenium/webdriver/bidi/protocol/error_code.rb +66 -0
  15. data/lib/selenium/webdriver/bidi/protocol/input.rb +101 -28
  16. data/lib/selenium/webdriver/bidi/protocol/log.rb +17 -6
  17. data/lib/selenium/webdriver/bidi/protocol/network.rb +146 -63
  18. data/lib/selenium/webdriver/bidi/protocol/permissions.rb +4 -2
  19. data/lib/selenium/webdriver/bidi/protocol/script.rb +264 -86
  20. data/lib/selenium/webdriver/bidi/protocol/session.rb +48 -3
  21. data/lib/selenium/webdriver/bidi/protocol/speculation.rb +4 -2
  22. data/lib/selenium/webdriver/bidi/protocol/storage.rb +32 -6
  23. data/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb +7 -2
  24. data/lib/selenium/webdriver/bidi/protocol/web_extension.rb +49 -10
  25. data/lib/selenium/webdriver/bidi/protocol.rb +3 -0
  26. data/lib/selenium/webdriver/bidi/serialization/record.rb +206 -33
  27. data/lib/selenium/webdriver/bidi/serialization/union.rb +50 -4
  28. data/lib/selenium/webdriver/bidi/serialization.rb +1 -1
  29. data/lib/selenium/webdriver/bidi/support/bidi_generate.rb +529 -45
  30. data/lib/selenium/webdriver/bidi/support/check_generated.rb +12 -4
  31. data/lib/selenium/webdriver/bidi/transport.rb +8 -3
  32. data/lib/selenium/webdriver/chrome/service.rb +4 -1
  33. data/lib/selenium/webdriver/chromium/driver.rb +0 -1
  34. data/lib/selenium/webdriver/chromium/options.rb +20 -0
  35. data/lib/selenium/webdriver/common/driver.rb +11 -0
  36. data/lib/selenium/webdriver/common/driver_extensions/has_log_events.rb +0 -7
  37. data/lib/selenium/webdriver/common/driver_extensions/has_network_interception.rb +0 -7
  38. data/lib/selenium/webdriver/common/driver_finder.rb +4 -3
  39. data/lib/selenium/webdriver/common/options.rb +21 -1
  40. data/lib/selenium/webdriver/common/proxy.rb +0 -8
  41. data/lib/selenium/webdriver/common/selenium_manager.rb +2 -1
  42. data/lib/selenium/webdriver/common/takes_screenshot.rb +1 -1
  43. data/lib/selenium/webdriver/common/websocket_connection.rb +64 -26
  44. data/lib/selenium/webdriver/common.rb +0 -1
  45. data/lib/selenium/webdriver/edge/service.rb +4 -1
  46. data/lib/selenium/webdriver/firefox/driver.rb +0 -3
  47. data/lib/selenium/webdriver/firefox/options.rb +19 -0
  48. data/lib/selenium/webdriver/firefox/profile.rb +13 -6
  49. data/lib/selenium/webdriver/remote/bidi_bridge.rb +39 -12
  50. data/lib/selenium/webdriver/remote/bridge.rb +1 -1
  51. data/lib/selenium/webdriver/remote/driver.rb +3 -1
  52. data/lib/selenium/webdriver/remote/http/default.rb +1 -1
  53. data/lib/selenium/webdriver/safari/options.rb +16 -1
  54. data/lib/selenium/webdriver/support/guards/guard.rb +20 -3
  55. data/lib/selenium/webdriver/support/guards.rb +9 -3
  56. data/lib/selenium/webdriver/version.rb +1 -1
  57. metadata +6 -3
  58. data/lib/selenium/webdriver/common/driver_extensions/has_bidi.rb +0 -36
@@ -29,7 +29,8 @@ module Selenium
29
29
  # @api private
30
30
  class Record < ::Data
31
31
  # Named Field, not Member, to avoid colliding with +::Data#members+.
32
- Field = ::Data.define(:name, :wire_key, :nullable, :ref, :list, :fixed, :enum, :required, :primitive)
32
+ Field = ::Data.define(:name, :wire_key, :nullable, :ref, :list, :fixed, :enum, :required, :primitive,
33
+ :scalar, :const)
33
34
 
34
35
  def self.define(**spec)
35
36
  extensible = spec.delete(:extensible) || false
@@ -59,7 +60,8 @@ module Selenium
59
60
  Field.new(name: name.to_sym, wire_key: meta.fetch(:wire_key, name.to_s),
60
61
  nullable: meta[:nullable] || false, ref: meta[:ref],
61
62
  list: meta[:list] || false, fixed: meta.fetch(:fixed, UNSET), enum: meta[:enum],
62
- required: meta.fetch(:required, true), primitive: meta[:primitive])
63
+ required: meta.fetch(:required, true), primitive: meta[:primitive],
64
+ scalar: meta[:scalar], const: meta.fetch(:const, UNSET))
63
65
  end
64
66
  private_class_method :field
65
67
 
@@ -77,18 +79,24 @@ module Selenium
77
79
  construct(**attributes)
78
80
  end
79
81
 
80
- # Inbound: builds from the wire. A missing required field raises (in +wire_value+),
81
- # enum tokens are mapped back to symbols and an unrecognized one raises (in +read+), and
82
- # extra keys are captured (extensible) or ignored (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.
83
86
  def from_json(json_payload)
84
87
  unless json_payload.is_a?(::Hash)
85
- 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}"
86
89
  end
87
90
 
88
91
  attributes = fields.to_h do |f|
89
92
  [f.name, wire_value(f, json_payload)]
90
93
  end
91
- attributes[:extensions] = extra(json_payload) if extensible?
94
+ undeclared = extra(json_payload)
95
+ if extensible?
96
+ attributes[:extensions] = undeclared # the spec sanctions these extras; preserve them silently
97
+ else
98
+ warn_undeclared(undeclared) unless undeclared.empty?
99
+ end
92
100
  construct(**attributes)
93
101
  end
94
102
 
@@ -96,9 +104,11 @@ module Selenium
96
104
 
97
105
  # Checks each field's value: a required field cannot be omitted (UNSET), a non-nullable
98
106
  # field cannot be nil (nil is neither a value nor the UNSET omit-sentinel, so it would be
99
- # silently dropped on the wire), and an enum field must be in its allowed set. The enum
100
- # constant is resolved lazily so a cross-domain enum need not be loaded first. Outbound
101
- # only (from +new+); inbound presence/enum are checked separately in +wire_value+/+read+.
107
+ # silently dropped on the wire), a nullable-const field must carry its literal (not some
108
+ # other value), a primitive field must be the matching Ruby type, and an enum field must be
109
+ # in its allowed set. The enum constant is resolved lazily so a cross-domain enum need not be
110
+ # loaded first. Outbound only (from +new+); inbound presence/primitive/enum are checked
111
+ # separately in +wire_value+/+read+.
102
112
  def validate_values(attributes)
103
113
  fields.each do |f|
104
114
  value = attributes[f.name]
@@ -106,11 +116,79 @@ module Selenium
106
116
  raise ::ArgumentError, "#{name}##{f.name} cannot be nil" if value.nil? && !f.nullable
107
117
  next if value.nil? || UNSET.equal?(value)
108
118
 
109
- check_outbound_shape(f, value)
110
- Serialization.validate!("#{name}##{f.name}", value, Protocol.const_get(f.enum)) if f.enum
119
+ validate_present(f, value)
120
+ end
121
+ end
122
+
123
+ # Checks a field that carries an actual value (neither omitted nor nil): a nullable-const
124
+ # field against its literal, list/scalar shape, primitive type (lists excepted, as inbound
125
+ # does), ref type, and enum membership (resolved lazily so a cross-domain enum need not load first).
126
+ def validate_present(field, value)
127
+ validate_const(field, value)
128
+ check_outbound_shape(field, value)
129
+ check_outbound_primitive(field, value) unless field.list
130
+ validate_ref(field, value) if field.ref
131
+ Serialization.validate!("#{name}##{field.name}", value, Protocol.const_get(field.enum)) if field.enum
132
+ end
133
+
134
+ # Outbound mirror of read_ref: a ref-typed value must be the type it declares, so a wrong
135
+ # record or a value no union variant accepts is a caller error caught here, not a browser
136
+ # round-trip. Shape is already checked, so a list is an Array.
137
+ def validate_ref(field, value)
138
+ klass = (@refs ||= {})[field.name] ||= Protocol.const_get(field.ref)
139
+ field.list ? validate_ref_list(field, klass, value) : validate_ref_value(field, klass, value)
140
+ end
141
+
142
+ # Mirrors read_list: a scalar field is a [key, value] map, a nested list recurses, otherwise
143
+ # each element is checked against the ref.
144
+ def validate_ref_list(field, klass, list)
145
+ list.each do |element|
146
+ if field.scalar
147
+ validate_ref_entry(field, klass, element)
148
+ elsif element.is_a?(::Array)
149
+ validate_ref_list(field, klass, element)
150
+ else
151
+ validate_ref_value(field, klass, element)
152
+ end
111
153
  end
112
154
  end
113
155
 
156
+ # A [key, value] map entry: the key may be a variant or a bare scalar, the value is a variant.
157
+ def validate_ref_entry(field, klass, element)
158
+ unless element.is_a?(::Array) && element.size == 2
159
+ raise ::ArgumentError, "#{name}##{field.name} expected a [key, value] pair, got #{element.inspect}"
160
+ end
161
+
162
+ key, value = element
163
+ key.is_a?(Serializable) ? validate_ref_value(field, klass, key) : check_outbound_scalar(field, key)
164
+ validate_ref_value(field, klass, value)
165
+ end
166
+
167
+ # A record ref must be an instance of that record; a union ref must be one the union accepts.
168
+ def validate_ref_value(field, klass, value)
169
+ return if klass < Union ? klass.valid_outbound?(value) : value.is_a?(klass)
170
+
171
+ raise ::ArgumentError, "#{name}##{field.name} expected #{field.ref}, got #{value.inspect}"
172
+ end
173
+
174
+ # Outbound mirror of scalar_value: a bare map key must match one of the arm's primitives.
175
+ def check_outbound_scalar(field, value)
176
+ checks = Array(field.scalar).filter_map { |primitive| PRIMITIVE_CHECKS[primitive] }
177
+ return if checks.empty? || checks.any? { |check| check.call(value) }
178
+
179
+ raise ::ArgumentError,
180
+ "#{name}##{field.name} expected #{Array(field.scalar).join(' or ')}, got #{value.inspect}"
181
+ end
182
+
183
+ # A nullable constant (`literal / null`) is caller-settable but its only non-null value is
184
+ # the literal, so a value that is neither the literal nor nil (nil is handled above) is a
185
+ # local error rather than a wire round-trip. A non-const field carries UNSET here and passes.
186
+ def validate_const(field, value)
187
+ return if UNSET.equal?(field.const) || value == field.const
188
+
189
+ raise ::ArgumentError, "#{name}##{field.name} must be #{field.const.inspect}, got #{value.inspect}"
190
+ end
191
+
114
192
  # Outbound mirror of check_shape: a list-typed arg must be an array, a scalar-shaped one
115
193
  # (enum or ref, not a list) must not — a local ArgumentError, not a wire round-trip.
116
194
  def check_outbound_shape(field, value)
@@ -121,34 +199,61 @@ module Selenium
121
199
  raise ::ArgumentError, "#{name}##{field.name} expected #{kind}, got #{value.inspect}"
122
200
  end
123
201
 
202
+ # Outbound mirror of check_primitive: a primitive-typed arg (`string`/`integer`/…) must be
203
+ # the matching Ruby type, so a caller mistake (a string width, a float count) is a local
204
+ # ArgumentError here rather than a rejection the browser reports a round-trip later. A field
205
+ # with no primitive descriptor (enum, ref, opaque) passes; lists are skipped, as inbound does.
206
+ def check_outbound_primitive(field, value)
207
+ check = PRIMITIVE_CHECKS[field.primitive]
208
+ return if check.nil? || check.call(value)
209
+
210
+ raise ::ArgumentError, "#{name}##{field.name} expected #{field.primitive}, got #{value.inspect}"
211
+ end
212
+
124
213
  def fixed?(field)
125
214
  !UNSET.equal?(field.fixed)
126
215
  end
127
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.
128
220
  def wire_value(field, json_payload)
129
221
  return field.fixed if fixed?(field)
130
222
  return read(field, json_payload[field.wire_key]) if json_payload.key?(field.wire_key)
131
223
  return UNSET unless field.required
132
224
 
133
- raise Error::WebDriverError, "#{name}##{field.name} is required but was missing from the response"
225
+ raise Error::SerializationError, "#{name}##{field.name} is required but was missing from the response"
134
226
  end
135
227
 
136
228
  def read(field, raw)
137
229
  if raw.nil?
138
230
  return raw if field.nullable
139
231
 
140
- 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"
141
233
  end
142
234
  check_shape(field, raw)
143
235
  return Serialization.to_symbol("#{name}##{field.name}", raw, enum_hash(field)) if field.enum
144
236
 
145
237
  if field.ref.nil?
146
- check_primitive(field, raw) unless field.list
147
- 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
148
243
  end
149
244
 
245
+ read_ref(field, raw)
246
+ end
247
+
248
+ # Reads a ref-typed value into its class. A `scalar` position is an inline union with
249
+ # a scalar arm collapsed onto its union ref (a map's string keys): a non-object leaf
250
+ # passes through instead of being handed to the object_only union, but only when it
251
+ # matches the arm's primitive (+scalar+ carries it). A list recurses per element.
252
+ def read_ref(field, raw)
150
253
  klass = (@refs ||= {})[field.name] ||= Protocol.const_get(field.ref)
151
- field.list ? read_list(raw, klass) : klass.from_json(raw)
254
+ return read_list(field, raw, klass) if field.list
255
+
256
+ field.scalar && !raw.is_a?(::Hash) ? scalar_value(field, raw) : klass.from_json(raw)
152
257
  end
153
258
 
154
259
  # A declared list must arrive as an array; a scalar-shaped field (enum or ref, not a
@@ -157,40 +262,91 @@ module Selenium
157
262
  return if field.list == raw.is_a?(::Array)
158
263
  return unless field.list || field.enum || field.ref
159
264
 
160
- raise Error::WebDriverError,
265
+ raise Error::SerializationError,
161
266
  "#{name}##{field.name} expected #{field.list ? 'a list' : 'a single value'}, got #{raw.inspect}"
162
267
  end
163
268
 
164
- # Ruby classes a checkable primitive admits. `number` is any Numeric (JSON has one
165
- # number type); `integer` requires an Integer — a browser emits `5`, not `5.0`, for an
166
- # integer (JS has no int/float split), so this rarely false-positives yet still rejects
167
- # a genuine non-integer like 1.5. A field with no primitive descriptor is left unchecked.
168
- PRIMITIVE_TYPES = {
169
- 'string' => [::String], 'boolean' => [::TrueClass, ::FalseClass],
170
- '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) }
171
279
  }.freeze
172
280
 
173
281
  def check_primitive(field, raw)
174
- expected = PRIMITIVE_TYPES[field.primitive]
175
- 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)
176
284
 
177
- 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}"
178
286
  end
179
287
 
180
288
  def enum_hash(field)
181
289
  (@enums ||= {})[field.name] ||= Protocol.const_get(field.enum)
182
290
  end
183
291
 
184
- # Parses each element, recursing into nested lists (e.g. a map's [key, value] pairs)
185
- # so their entries become typed too.
186
- def read_list(raw, klass)
187
- raw.map { |element| element.is_a?(::Array) ? read_list(element, klass) : klass.from_json(element) }
292
+ # Parses each element. A `scalar` field is a map encoded as `[key, value]` pairs, so
293
+ # every element must be a 2-item pair — each is read as one, and a malformed entry is
294
+ # rejected. Non-scalar lists recurse into nested lists; other elements deserialize.
295
+ def read_list(field, raw, klass)
296
+ raw.map do |element|
297
+ if field.scalar
298
+ read_map_entry(field, element, klass)
299
+ elsif element.is_a?(::Array)
300
+ read_list(field, element, klass)
301
+ else
302
+ klass.from_json(element)
303
+ end
304
+ end
305
+ end
306
+
307
+ # A map entry is a `[key, value]` pair. The key is `Ref / text` — an object key
308
+ # deserializes, a bare-string key passes through once validated against the arm's
309
+ # primitive. The value is the object-only Ref and always deserializes, so a bare scalar
310
+ # there is rejected (object_only holds at the value position). A non-pair element is a
311
+ # malformed entry and is rejected outright.
312
+ def read_map_entry(field, element, klass)
313
+ unless element.is_a?(::Array) && element.size == 2
314
+ raise Error::SerializationError,
315
+ "#{name}##{field.name} expected a [key, value] pair, got #{element.inspect}"
316
+ end
317
+
318
+ key, value = element
319
+ key = key.is_a?(::Hash) ? klass.from_json(key) : scalar_value(field, key)
320
+ [key, klass.from_json(value)]
321
+ end
322
+
323
+ # A bare scalar at a scalar-tolerant union position must match one of the union's
324
+ # scalar-arm primitives (+scalar+ is a primitive name or an array of them); a
325
+ # wrong-typed scalar (a number where a string is expected) is a wire error, not
326
+ # something to pass through. An unrecognized primitive (none in PRIMITIVE_CHECKS) is
327
+ # left unchecked, matching the lenient default elsewhere.
328
+ def scalar_value(field, value)
329
+ checks = Array(field.scalar).filter_map { |primitive| PRIMITIVE_CHECKS[primitive] }
330
+ return value if checks.empty? || checks.any? { |check| check.call(value) }
331
+
332
+ raise Error::SerializationError,
333
+ "#{name}##{field.name} expected #{Array(field.scalar).join(' or ')}, got #{value.inspect}"
188
334
  end
189
335
 
190
336
  def extra(json_payload)
191
337
  known = (@wire_keys ||= fields.map(&:wire_key))
192
338
  json_payload.except(*known)
193
339
  end
340
+
341
+ # Forward-compat signal: a property a closed type does not model is dropped and warned so
342
+ # schema drift is visible (an extensible type keeps its extras silently — the spec sanctions
343
+ # them). Tagged +:bidi_undeclared_property+ so a caller can silence it via +logger.ignore+.
344
+ def warn_undeclared(undeclared)
345
+ undeclared.each_key do |key|
346
+ WebDriver.logger.warn("#{name} received an undeclared property: #{key.inspect}",
347
+ id: :bidi_undeclared_property)
348
+ end
349
+ end
194
350
  end
195
351
 
196
352
  # @api private
@@ -215,9 +371,26 @@ module Selenium
215
371
  value = Serialization.to_wire(value, Protocol.const_get(f.enum)) if f.enum
216
372
  payload[f.wire_key] = Serializable.as_json(value)
217
373
  end
218
- payload.merge!(extensions) if self.class.extensible? && !extensions.empty?
374
+ merge_extensions!(payload) if self.class.extensible? && !extensions.empty?
219
375
  payload
220
376
  end
377
+
378
+ private
379
+
380
+ # Merge the passthrough extras onto the wire, erroring rather than letting an extra whose key
381
+ # is a declared field's wire key silently clobber that typed value; an extra is by definition
382
+ # a field the spec does not declare. Keys are stringified first so a symbol key (e.g. `name:`)
383
+ # cannot slip past the guard and then reappear as a duplicate wire key once serialized. The
384
+ # single gate every outbound path funnels through: +new+, +with+, and in-place mutation.
385
+ def merge_extensions!(payload)
386
+ extras = extensions.transform_keys(&:to_s)
387
+ collisions = extras.keys & self.class.fields.map(&:wire_key)
388
+ unless collisions.empty?
389
+ raise ::ArgumentError, "#{self.class.name} extensions shadow declared fields: #{collisions.join(', ')}"
390
+ end
391
+
392
+ payload.merge!(extras)
393
+ end
221
394
  end
222
395
  end
223
396
  end
@@ -44,14 +44,32 @@ module Selenium
44
44
  def presence(rules) = @presence = rules
45
45
  def fallback(path) = @fallback = path
46
46
 
47
- # A non-Hash payload is a bare scalar arm (e.g. input.Origin's "viewport") with
48
- # no object to dispatch on, so it is returned unchanged.
47
+ # Declared (via the schema's `objectOnly` signal) on a union whose every arm is an
48
+ # object, so a non-Hash payload is a schema violation rather than a scalar arm.
49
+ def object_only = @object_only = true
50
+
51
+ # Declared (via the schema's `scalarValues` signal) on a non-object_only union whose
52
+ # bare-scalar arms are a fixed set of literals (input.Origin's "viewport" / "pointer").
53
+ # An outbound scalar outside that set matches no arm, so it is a caller error.
54
+ def scalar_values(*values) = @scalar_values = values
55
+
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.
49
58
  def from_json(json_payload)
50
- return json_payload unless json_payload.is_a?(::Hash)
59
+ unless json_payload.is_a?(::Hash)
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}"
68
+ end
51
69
 
52
70
  variant = select(json_payload)
53
71
  unless variant
54
- raise Error::WebDriverError,
72
+ raise Error::SerializationError,
55
73
  "#{name} received a variant not in this Selenium's BiDi schema: #{json_payload.inspect}"
56
74
  end
57
75
  Protocol.const_get(variant).from_json(json_payload)
@@ -75,8 +93,36 @@ module Selenium
75
93
  raise ::ArgumentError, "invalid combination for #{name}: #{invalid.join(', ')}"
76
94
  end
77
95
 
96
+ # Outbound mirror of from_json: is +value+ one this union accepts? Any variant is accepted,
97
+ # and a variant that is itself a union recurses (e.g. LocalValue's RemoteReference fallback).
98
+ # A non-object_only union (e.g. input.Origin) also admits one of its pinned bare-scalar
99
+ # literals; an object (a Hash or another union's record) that matched no variant does not.
100
+ def valid_outbound?(value)
101
+ return true if variant_refs.any? { |ref| variant_accepts?(ref, value) }
102
+
103
+ !@object_only && scalar_arm?(value)
104
+ end
105
+
78
106
  private
79
107
 
108
+ # A bare-scalar arm must be one of the literals the schema pinned for this union
109
+ # (scalar_values, e.g. input.Origin's "viewport" / "pointer"). The generator guarantees a
110
+ # non-object_only union declares them, so no runtime guard is needed here.
111
+ def scalar_arm?(value)
112
+ @scalar_values.include?(value)
113
+ end
114
+
115
+ # Every variant's class name: the discriminated table, the presence paths, and the fallback.
116
+ def variant_refs
117
+ @variant_refs ||= [*@variants&.values, *@presence&.keys, @fallback].compact
118
+ end
119
+
120
+ # A variant that is itself a union recurses; a record variant is matched by instance.
121
+ def variant_accepts?(ref, value)
122
+ klass = (@variant_classes ||= {})[ref] ||= Protocol.const_get(ref)
123
+ klass < Union ? klass.valid_outbound?(value) : value.is_a?(klass)
124
+ end
125
+
80
126
  # The discriminator value may legitimately be null (e.g. script.NullValue's
81
127
  # "null" tag), so it is matched by key presence.
82
128
  def select(json_payload)
@@ -67,7 +67,7 @@ module Selenium
67
67
  return value if value.nil?
68
68
  return value.map { |element| to_symbol(name, element, enum) } if value.is_a?(::Array)
69
69
 
70
- 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}")
71
71
  end
72
72
  end
73
73
  end # BiDi