diff --git a/rb/.rubocop.yml b/rb/.rubocop.yml index 5896e31689c8a..9bcab28514e7c 100644 --- a/rb/.rubocop.yml +++ b/rb/.rubocop.yml @@ -64,6 +64,7 @@ Metrics/ModuleLength: Max: 110 Exclude: - 'lib/selenium/webdriver/common/platform.rb' + - 'lib/selenium/webdriver/bidi/serialization/record.rb' - 'lib/selenium/webdriver/bidi/support/bidi_generate.rb' - 'spec/**/*' diff --git a/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb b/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb index d51de6cf9d2a3..e1973e109eb07 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/browsing_context.rb @@ -393,7 +393,7 @@ class ClipRectangle < Serialization::Union # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#cddl-type-browsingcontextsetbypasscspparameters SetBypassCSPParameters = Serialization::Record.define( - bypass: {wire_key: 'bypass', nullable: true}, + bypass: {wire_key: 'bypass', nullable: true, const: true}, contexts: {wire_key: 'contexts', required: false, list: true}, user_contexts: {wire_key: 'userContexts', required: false, list: true} ) diff --git a/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb b/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb index 4cb7a12aa7407..2066b01fbf26d 100644 --- a/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb +++ b/rb/lib/selenium/webdriver/bidi/protocol/emulation.rb @@ -176,7 +176,7 @@ class SetGeolocationOverrideParameters < Serialization::Union # @see https://www.selenium.dev/documentation/warnings/bidi-implementation/ # @see https://w3c.github.io/webdriver-bidi/#cddl-type-emulationsetscriptingenabledparameters SetScriptingEnabledParameters = Serialization::Record.define( - enabled: {wire_key: 'enabled', nullable: true}, + enabled: {wire_key: 'enabled', nullable: true, const: false}, contexts: {wire_key: 'contexts', required: false, list: true}, user_contexts: {wire_key: 'userContexts', required: false, list: true} ) diff --git a/rb/lib/selenium/webdriver/bidi/serialization/record.rb b/rb/lib/selenium/webdriver/bidi/serialization/record.rb index a8364bc876e53..05450bc311158 100644 --- a/rb/lib/selenium/webdriver/bidi/serialization/record.rb +++ b/rb/lib/selenium/webdriver/bidi/serialization/record.rb @@ -29,7 +29,8 @@ module Serialization # @api private class Record < ::Data # Named Field, not Member, to avoid colliding with +::Data#members+. - Field = ::Data.define(:name, :wire_key, :nullable, :ref, :list, :fixed, :enum, :required, :primitive, :scalar) + Field = ::Data.define(:name, :wire_key, :nullable, :ref, :list, :fixed, :enum, :required, :primitive, + :scalar, :const) def self.define(**spec) extensible = spec.delete(:extensible) || false @@ -60,7 +61,7 @@ def self.field(name, meta) nullable: meta[:nullable] || false, ref: meta[:ref], list: meta[:list] || false, fixed: meta.fetch(:fixed, UNSET), enum: meta[:enum], required: meta.fetch(:required, true), primitive: meta[:primitive], - scalar: meta[:scalar]) + scalar: meta[:scalar], const: meta.fetch(:const, UNSET)) end private_class_method :field @@ -97,9 +98,10 @@ def from_json(json_payload) # Checks each field's value: a required field cannot be omitted (UNSET), a non-nullable # field cannot be nil (nil is neither a value nor the UNSET omit-sentinel, so it would be - # silently dropped on the wire), and an enum field must be in its allowed set. The enum - # constant is resolved lazily so a cross-domain enum need not be loaded first. Outbound - # only (from +new+); inbound presence/enum are checked separately in +wire_value+/+read+. + # silently dropped on the wire), a nullable-const field must carry its literal (not some + # other value), and an enum field must be in its allowed set. The enum constant is resolved + # lazily so a cross-domain enum need not be loaded first. Outbound only (from +new+); + # inbound presence/enum are checked separately in +wire_value+/+read+. def validate_values(attributes) fields.each do |f| value = attributes[f.name] @@ -107,11 +109,21 @@ def validate_values(attributes) raise ::ArgumentError, "#{name}##{f.name} cannot be nil" if value.nil? && !f.nullable next if value.nil? || UNSET.equal?(value) + validate_const(f, value) check_outbound_shape(f, value) Serialization.validate!("#{name}##{f.name}", value, Protocol.const_get(f.enum)) if f.enum end 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. + def validate_const(field, value) + return if UNSET.equal?(field.const) || value == field.const + + raise ::ArgumentError, "#{name}##{field.name} must be #{field.const.inspect}, got #{value.inspect}" + end + # Outbound mirror of check_shape: a list-typed arg must be an array, a scalar-shaped one # (enum or ref, not a list) must not — a local ArgumentError, not a wire round-trip. def check_outbound_shape(field, value) diff --git a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb index 0e88f5b39451f..4056e92142ee8 100644 --- a/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb +++ b/rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb @@ -236,26 +236,35 @@ def type_entry = "'#{wire_name}' => #{payload_ref || 'nil'}" # ref is the Protocol-relative class path for a nested structured field (nil # for a scalar/opaque field); list wraps it in an array. wire_key is the exact # JSON payload key (the schema's `wire` name, baked verbatim). - FieldIR = Struct.new(:ruby_name, :wire_key, :required, :nullable, :ref, :list, :enum, :primitive, :scalar, :rbs, - keyword_init: true) do + FieldIR = Struct.new(:ruby_name, :wire_key, :required, :nullable, :ref, :list, :enum, :primitive, :scalar, :const, + :rbs, keyword_init: true) do # A `Serialization::Record.define` spec entry: `name: 'jsonKey'` shorthand, or # `name: {wire_key:, …}` when the field carries JSON facts beyond its name. # enum carries the allowed-values constant path, validated at construction. def spec_entry(indent = 0) - meta = [] - meta << 'required: false' unless required - meta << 'nullable: true' if nullable - meta << "ref: '#{ref}'" if ref - meta << 'list: true' if list - meta << "scalar: #{scalar_literal}" if scalar - meta << "enum: '#{enum}'" if enum - meta << "primitive: '#{primitive}'" if primitive + meta = value_facts return "#{ruby_name}: '#{wire_key}'" if meta.empty? meta.unshift("wire_key: '#{wire_key}'") BiDiGenerate.wrap_call("#{ruby_name}: ", meta, indent, open: '{', close: '}') end + # The JSON facts beyond the field's name, in the order Record.define reads them. A + # nullable const (`literal / null`) carries `const:` so the runtime rejects a value that + # is neither the literal nor null; `const.nil?` means the field has no const at all. + def value_facts + facts = [] + facts << 'required: false' unless required + facts << 'nullable: true' if nullable + facts << "const: #{BiDiGenerate.ruby_literal(const)}" unless const.nil? + facts << "ref: '#{ref}'" if ref + facts << 'list: true' if list + facts << "scalar: #{scalar_literal}" if scalar + facts << "enum: '#{enum}'" if enum + facts << "primitive: '#{primitive}'" if primitive + facts + end + # The `scalar` primitive(s) a bare non-object wire value must match at a scalar-tolerant # union position: a single primitive string, or an array when the union's scalar arms differ. def scalar_literal @@ -655,7 +664,23 @@ def field_ir(field) required: field['required'], nullable: resolved[:nullable], ref: resolved[:ref], list: resolved[:list], enum: enum_const(field['type']), primitive: leaf_primitive(field['type']), scalar: resolved[:scalar], - rbs: resolved[:rbs]) + const: leaf_const(field['type']), rbs: resolved[:rbs]) + end + + # The literal value of a const field, following alias chains, so the runtime can reject a + # value that is neither the literal nor null (a `literal / null` param such as + # emulation.setScriptingEnabled's `enabled`). Nil for any non-const node — const literals are + # never nil, so nil unambiguously means "no const" (a null value is carried by `nullable`). + def leaf_const(node, seen = {}) + return node['const'] if node.key?('const') + return nil unless node.key?('ref') + + name = node['ref'] + type = @types[name] + return nil if seen[name] || type.nil? || type['kind'] != 'alias' + + seen[name] = true + leaf_const(type['type'], seen) end # The runtime-checkable scalar primitive of a field, following alias chains so a diff --git a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs index ba4fbf6504b69..214a9958dd4a6 100644 --- a/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs +++ b/rb/sig/lib/selenium/webdriver/bidi/serialization.rbs @@ -60,6 +60,8 @@ module Selenium def validate_values: (Hash[Symbol, untyped] attributes) -> void + def validate_const: (untyped field, untyped value) -> void + def check_outbound_shape: (untyped field, untyped value) -> void def fixed?: (untyped field) -> bool diff --git a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb index b0adce8300080..3cbb110fe79ae 100644 --- a/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb +++ b/rb/spec/unit/selenium/webdriver/bidi/serialization_spec.rb @@ -199,6 +199,13 @@ def valid_cookie_attrs expect(BrowsingContext::SetBypassCSPParameters.new(bypass: true).as_json).to eq('bypass' => true) expect(BrowsingContext::SetBypassCSPParameters.new(bypass: nil).as_json).to eq('bypass' => nil) end + + it 'rejects a value that is neither the literal nor null, before it reaches the wire' do + expect { BrowsingContext::SetBypassCSPParameters.new(bypass: false) } + .to raise_error(ArgumentError, /bypass must be true/) + expect { Emulation::SetScriptingEnabledParameters.new(enabled: true) } + .to raise_error(ArgumentError, /enabled must be false/) + end end describe 'extensible records' do