Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion rb/lib/selenium/webdriver/bidi/serialization/record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,64 @@ def validate_values(attributes)

# Checks a field that carries an actual value (neither omitted nor nil): a nullable-const
# field against its literal, list/scalar shape, primitive type (lists excepted, as inbound
# does), and enum membership (resolved lazily so a cross-domain enum need not load first).
# does), ref type, and enum membership (resolved lazily so a cross-domain enum need not load first).
def validate_present(field, value)
validate_const(field, value)
check_outbound_shape(field, value)
check_outbound_primitive(field, value) unless field.list
validate_ref(field, value) if field.ref
Serialization.validate!("#{name}##{field.name}", value, Protocol.const_get(field.enum)) if field.enum
end

# Outbound mirror of read_ref: a ref-typed value must be the type it declares, so a wrong
# record or a value no union variant accepts is a caller error caught here, not a browser
# round-trip. Shape is already checked, so a list is an Array.
def validate_ref(field, value)
klass = (@refs ||= {})[field.name] ||= Protocol.const_get(field.ref)
field.list ? validate_ref_list(field, klass, value) : validate_ref_value(field, klass, value)
end

# Mirrors read_list: a scalar field is a [key, value] map, a nested list recurses, otherwise
# each element is checked against the ref.
def validate_ref_list(field, klass, list)
list.each do |element|
if field.scalar
validate_ref_entry(field, klass, element)
elsif element.is_a?(::Array)
validate_ref_list(field, klass, element)
else
validate_ref_value(field, klass, element)
end
end
end

# A [key, value] map entry: the key may be a variant or a bare scalar, the value is a variant.
def validate_ref_entry(field, klass, element)
unless element.is_a?(::Array) && element.size == 2
raise ::ArgumentError, "#{name}##{field.name} expected a [key, value] pair, got #{element.inspect}"
end

key, value = element
key.is_a?(Serializable) ? validate_ref_value(field, klass, key) : check_outbound_scalar(field, key)
validate_ref_value(field, klass, value)
end

# A record ref must be an instance of that record; a union ref must be one the union accepts.
def validate_ref_value(field, klass, value)
return if klass < Union ? klass.valid_outbound?(value) : value.is_a?(klass)

raise ::ArgumentError, "#{name}##{field.name} expected #{field.ref}, got #{value.inspect}"
end

# Outbound mirror of scalar_value: a bare map key must match one of the arm's primitives.
def check_outbound_scalar(field, value)
expected = Array(field.scalar).flat_map { |primitive| PRIMITIVE_TYPES[primitive] || [] }
return if expected.empty? || expected.any? { |type| value.is_a?(type) }

raise ::ArgumentError,
"#{name}##{field.name} expected #{Array(field.scalar).join(' or ')}, got #{value.inspect}"
end

# A nullable constant (`literal / null`) is caller-settable but its only non-null value is
# the literal, so a value that is neither the literal nor nil (nil is handled above) is a
# local error rather than a wire round-trip. A non-const field carries UNSET here and passes.
Expand Down
28 changes: 28 additions & 0 deletions rb/lib/selenium/webdriver/bidi/serialization/union.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,36 @@ def build(**kwargs)
raise ::ArgumentError, "invalid combination for #{name}: #{invalid.join(', ')}"
end

# Outbound mirror of from_json: is +value+ one this union accepts? Any variant is accepted,
# and a variant that is itself a union recurses (e.g. LocalValue's RemoteReference fallback).
# A union with a bare-scalar arm (not object_only, e.g. input.Origin's "viewport") also admits
# a bare primitive; an object (a Hash or another union's record) that matched no variant does not.
def valid_outbound?(value)
return true if variant_refs.any? { |ref| variant_accepts?(ref, value) }

!@object_only && scalar_arm?(value)
end
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

private

# A bare-scalar arm is a primitive. An object (a Hash, or a typed record from any union) had
# to match a variant, so from_json dispatches it rather than passing it through — and here it
# is rejected when no variant matched.
def scalar_arm?(value)
value.is_a?(::String) || value.is_a?(::Numeric) || value == true || value == false
end
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

# Every variant's class name: the discriminated table, the presence paths, and the fallback.
def variant_refs
@variant_refs ||= [*@variants&.values, *@presence&.keys, @fallback].compact
end

# A variant that is itself a union recurses; a record variant is matched by instance.
def variant_accepts?(ref, value)
klass = (@variant_classes ||= {})[ref] ||= Protocol.const_get(ref)
klass < Union ? klass.valid_outbound?(value) : value.is_a?(klass)
end

# The discriminator value may legitimately be null (e.g. script.NullValue's
# "null" tag), so it is matched by key presence.
def select(json_payload)
Expand Down
18 changes: 18 additions & 0 deletions rb/sig/lib/selenium/webdriver/bidi/serialization.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ module Selenium

def validate_present: (untyped field, untyped value) -> void

def validate_ref: (untyped field, untyped value) -> void

def validate_ref_list: (untyped field, untyped klass, untyped list) -> void

def validate_ref_entry: (untyped field, untyped klass, untyped element) -> void

def validate_ref_value: (untyped field, untyped klass, untyped value) -> void

def check_outbound_scalar: (untyped field, untyped value) -> void

def validate_const: (untyped field, untyped value) -> void

def check_outbound_shape: (untyped field, untyped value) -> void
Expand Down Expand Up @@ -129,8 +139,16 @@ module Selenium

def self.build: (**untyped kwargs) -> untyped

def self.valid_outbound?: (untyped value) -> bool

private

def self.scalar_arm?: (untyped value) -> bool

def self.variant_refs: () -> Array[String]

def self.variant_accepts?: (String ref, untyped value) -> bool

def self.select: (Hash[untyped, untyped] json_payload) -> untyped

def self.outbound_variant: (Hash[untyped, untyped] kwargs) -> untyped
Expand Down
88 changes: 88 additions & 0 deletions rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,94 @@ def moz_install(**kwargs)
end
end

describe 'outbound ref validation' do
# A record-typed ref: the value must be an instance of that exact record. A different
# record (even a sibling reference type) is a caller error caught before the wire.
it 'accepts the declared record for a record-typed ref' do
params = Input::SetFilesParameters.new(
context: 'c', element: Script::SharedReference.new(shared_id: 's1'), files: []
)

expect(params.element).to be_a(Script::SharedReference)
end

it 'rejects a wrong record for a record-typed ref' do
wrong = Script::RemoteObjectReference.new(handle: 'h')

expect { Input::SetFilesParameters.new(context: 'c', element: wrong, files: []) }
.to raise_error(ArgumentError, /SetFilesParameters#element expected Script::SharedReference/)
end

# A union-typed ref accepts any of the union's declared variants (decision 1), so a Cookie
# value may be either BytesValue arm.
it 'accepts any declared variant for a union-typed ref' do
string_cookie = Network::Cookie.new(**valid_cookie_attrs, value: Network::StringValue.new(value: 'YQ=='))
base64_cookie = Network::Cookie.new(**valid_cookie_attrs, value: Network::Base64Value.new(value: 'YQ=='))

expect(string_cookie.value).to be_a(Network::StringValue)
expect(base64_cookie.value).to be_a(Network::Base64Value)
end

# A record from a different union with the same wire shape is still not a BytesValue
# variant, so it is rejected rather than duck-typed onto the wire.
it 'rejects a variant from a different union for a union-typed ref' do
expect { Network::Cookie.new(**valid_cookie_attrs, value: Script::StringValue.new(value: 'x')) }
.to raise_error(ArgumentError, /Cookie#value expected Network::BytesValue/)
end

# BytesValue is object_only, so a bare scalar cannot match any arm — the outbound mirror of
# rejecting a non-object where a typed object is expected (decision 4).
it 'rejects a bare scalar for an object-only union ref' do
expect { Network::Cookie.new(**valid_cookie_attrs, value: 'plain') }
.to raise_error(ArgumentError, /Cookie#value expected Network::BytesValue/)
end

# A union with a bare-scalar arm (input.Origin's "viewport") still admits that scalar, but a
# record from another union remains a cross-union mismatch.
it 'accepts a bare-scalar arm for a non-object-only union ref' do
expect(Input::PointerMoveAction.new(x: 0, y: 0, origin: 'viewport').origin).to eq('viewport')
end

it 'rejects a cross-union variant even where a scalar arm exists' do
expect { Input::PointerMoveAction.new(x: 0, y: 0, origin: Script::StringValue.new(value: 'x')) }
.to raise_error(ArgumentError, /PointerMoveAction#origin expected Input::Origin/)
end

# A raw Hash is an object that matched no variant, not a bare-scalar arm, so it is rejected
# rather than passed through untyped.
it 'rejects a raw Hash for a union ref with a scalar arm' do
expect { Input::PointerMoveAction.new(x: 0, y: 0, origin: {type: 'element'}) }
.to raise_error(ArgumentError, /PointerMoveAction#origin expected Input::Origin/)
end

# A ref list validates every element against the ref, so one bad element is rejected even
# when its siblings are valid variants.
it 'accepts a list whose every element is a declared variant' do
array = Script::ArrayLocalValue.new(value: [Script::StringValue.new(value: 'x'),
Script::NumberValue.new(value: 1)])

expect(array.value.size).to eq(2)
end

it 'validates each element of a ref list, rejecting a non-variant element' do
expect { Script::ArrayLocalValue.new(value: [Script::NumberValue.new(value: 1), 42]) }
.to raise_error(ArgumentError, /ArrayLocalValue#value expected Script::LocalValue, got 42/)
end

# A scalar-arm map keeps its bare-string key while still typing the value: a wrong-typed
# value (not a LocalValue variant) at the value position is rejected before the wire.
it 'rejects a non-variant value at a scalar-arm map position' do
expect { Script::ObjectLocalValue.new(value: [['k', 'not-a-value']]) }
.to raise_error(ArgumentError, /ObjectLocalValue#value expected Script::LocalValue, got "not-a-value"/)
end

# The bare key at a scalar-arm map position must still match the arm's primitive.
it 'rejects a wrong-typed bare key at a scalar-arm map position' do
expect { Script::ObjectLocalValue.new(value: [[42, Script::StringValue.new(value: 'x')]]) }
.to raise_error(ArgumentError, /ObjectLocalValue#value expected string, got 42/)
end
end

describe 'enum symbol coercion' do
it 'takes an idiomatic symbol and serializes the wire token (kebab included)' do
params = Bluetooth::SimulateAdapterParameters.new(context: 'c', state: :powered_off)
Expand Down