Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 23 additions & 8 deletions javascript/selenium-webdriver/normalize_bidi_ast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ function groupRef(value) {
return { Type: 'group', Value: value, Unwrapped: false }
}

/** True when `entry` is a string/number/bool literal (`{Type:'literal', Value}`). */
function isLiteral(entry) {
return entry && typeof entry === 'object' && entry.Type === 'literal'
}

/** True when `entry` is the CDDL null keyword (bare `'null'`) or a `nil`/`null` prelude ref. */
function isNullArm(entry) {
return entry === 'null' || (isGroupRef(entry) && (entry.Value === 'null' || entry.Value === 'nil'))
}
Comment thread
titusfortner marked this conversation as resolved.

/**
* Drop the leading run of `label` that restates `ownerLocal`, backing off to a
* camelCase boundary, so `ContinueWithAuthParameters` + `ContinueWithAuthCredentials`
Expand Down Expand Up @@ -151,9 +161,11 @@ function eachPropertyDeep(properties, fn) {
}

/**
* Rewrite fields whose type is a union of >= 2 string literals into a reference
* to a synthetic enum def, and append those enum defs. Single-literal fields
* (discriminators) are left untouched. Returns a new AST array.
* Rewrite fields whose type is a choice of >= 2 string literals (optionally with a
* null alternative) into a reference to a synthetic enum def, and append those enum
* defs. A null alternative is kept on the field so the enum stays nullable; the enum
* def itself holds only the literals. Single-literal fields (discriminators) are left
* untouched. Returns a new AST array.
* @param {object[]} ast The AST to transform.
* @returns {object[]} A new AST array with inline enums hoisted to named defs.
*/
Expand All @@ -167,9 +179,12 @@ export function hoistInlineEnums(ast) {
const owner = splitName(def.Name ?? '')
eachPropertyDeep(def.Properties, (prop) => {
const entries = typeList(prop.Type)
const allLiterals =
entries.length >= 2 && entries.every((e) => e && typeof e === 'object' && e.Type === 'literal')
if (!allLiterals) return
const literals = entries.filter(isLiteral)
const nullArms = entries.filter(isNullArm)
// Hoist a choice of >= 2 string literals, tolerating a null alternative so a nullable inline
// enum (`("a" / "b") / null`) is still named. The null stays on the field (below), never in the
// enum def; anything else in the choice (a ref, a single literal discriminator) is left untouched.
if (literals.length < 2 || literals.length + nullArms.length !== entries.length) return

const base = pascal(prop.Name) || `Value${created.length}`
const localName = `${owner.local}${base}`
Expand All @@ -179,13 +194,13 @@ export function hoistInlineEnums(ast) {
Type: 'variable',
Name: synthName,
IsChoiceAddition: false,
PropertyType: entries.map((e) => structuredClone(e)),
PropertyType: literals.map((e) => structuredClone(e)),
Comments: prop.Comments ?? [],
'x-selenium-synthetic': true,
'x-selenium-owner': def.Name,
'x-selenium-label': base,
})
prop.Type = [groupRef(synthName)]
prop.Type = [groupRef(synthName), ...nullArms.map((e) => structuredClone(e))]
})
}

Expand Down
12 changes: 12 additions & 0 deletions javascript/selenium-webdriver/normalize_bidi_ast_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ describe('hoistInlineEnums', () => {
)
})

it('hoists a nullable literal choice, keeping the null on the field and out of the enum', () => {
const ast = [def('x.T', [field('scrollbarType', [lit('classic'), lit('overlay'), 'null'])])]
const out = hoistInlineEnums(ast)

const enumName = 'x.TScrollbarType'
assert.deepEqual(byName(out, 'x.T').Properties[0].Type, [ref(enumName), 'null'])
assert.deepEqual(
byName(out, enumName).PropertyType.map((e) => e.Value),
['classic', 'overlay'],
)
})

it('does NOT hoist a single-literal (discriminator) field', () => {
const ast = [def('x.T', [field('type', [lit('password')])])]
const out = hoistInlineEnums(ast)
Expand Down
2 changes: 1 addition & 1 deletion javascript/selenium-webdriver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"cddl": "^0.21.0",
"cddl": "^0.21.1",
"cddl2ts": "^0.10.0",
"clean-jsdoc-theme": "^4.3.3",
"eslint": "^10.7.0",
Expand Down
8 changes: 6 additions & 2 deletions javascript/selenium-webdriver/project_bidi_schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,12 @@ function projectEntry(e) {
if (e.Type === 'array') return { list: projectRef(e.Values?.[0]?.Type) }
if (e.Type === 'map') return { map: projectRef(e.ValueType ?? e.Values?.[0]?.Type), extensible: true }
if (e.Type === 'range') {
const intRange = Number.isInteger(e.Value?.Min?.Value) && Number.isInteger(e.Value?.Max?.Value)
return { primitive: intRange ? 'integer' : 'number' } // e.g. js-uint (0..MAX) vs scale (0.1..2)
// A bound written as a float (`1.0`) parses to an integer `Value` carrying an `IsFloat`
// marker; consult it so `(0.0..1.0)` is a number range, not β€” as its integral bounds alone
// would read β€” an integer one. A bound with no marker falls back to its value's integralness.
const intBound = (b) => b && !b.IsFloat && Number.isInteger(b.Value)
const intRange = intBound(e.Value?.Min) && intBound(e.Value?.Max)
return { primitive: intRange ? 'integer' : 'number' } // e.g. js-uint (0..MAX) vs latitude (-90.0..90.0)
}
return { primitive: PRIMITIVES[e.Type] ?? 'unknown' }
}
Expand Down
24 changes: 16 additions & 8 deletions javascript/selenium-webdriver/project_bidi_schema_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,20 @@ describe('projectType (list / union / alias defs)', () => {
Name: 'x.F',
PropertyType: [{ Type: 'range', Value: { Min: { Value: 0.1 }, Max: { Value: 2 } } }],
},
{
// `(0.0..1.0)` β€” integral bounds, but the `IsFloat` marker makes it a number range.
Type: 'variable',
Name: 'x.W',
PropertyType: [
{ Type: 'range', Value: { Min: { Value: 0, IsFloat: true }, Max: { Value: 1, IsFloat: true } } },
],
},
],
{},
)
assert.deepEqual(s.types['x.U'], { kind: 'alias', type: { primitive: 'integer' } })
assert.deepEqual(s.types['x.F'], { kind: 'alias', type: { primitive: 'number' } })
assert.deepEqual(s.types['x.W'], { kind: 'alias', type: { primitive: 'number' } })
})

it('unwraps a control-operator (.default / .ge) wrapped field type to its inner type', () => {
Expand Down Expand Up @@ -446,15 +455,14 @@ describe('schema signals (objectOnly / preserveExtras / enum primitive)', () =>
assert.deepEqual(checkSchema(s), [])
})

it('types an inline (non-hoisted) literal choice with the primitive its literals share', () => {
// A nullable literal choice (`("classic" / "overlay") / null`) the normalizer leaves
// inline β€” carry `primitive: string` so the scalar is typed rather than opaque.
it('hoists a nullable literal choice to a named enum, referenced with the null preserved', () => {
// A nullable literal choice (`("classic" / "overlay") / null`) is hoisted (normalize_bidi_ast)
// to a named enum and referenced with the null kept on the field β€” a nullable enum ref, not an
// inline enum carrying a primitive.
const s = projectSchema([group('x.R', [field('kind', [lit('classic'), lit('overlay'), 'null'])])], {})
assert.deepEqual(s.types['x.R'].fields[0].type, {
enum: ['classic', 'overlay'],
primitive: 'string',
nullable: true,
})
assert.deepEqual(s.types['x.R'].fields[0].type, { ref: 'x.RKind', nullable: true })
assert.equal(s.types['x.RKind'].kind, 'enum')
assert.deepEqual(s.types['x.RKind'].values, ['classic', 'overlay'])
assert.deepEqual(checkSchema(s), [])
})

Expand Down
13 changes: 11 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ class Locator < Serialization::Union
# @see https://w3c.github.io/webdriver-bidi/#cddl-type-browsingcontextimageformat
ImageFormat = Serialization::Record.define(
type: {wire_key: 'type', primitive: 'string'},
quality: {wire_key: 'quality', required: false, primitive: 'integer'}
quality: {wire_key: 'quality', required: false, primitive: 'number'}
)

# @api private
Expand Down
22 changes: 18 additions & 4 deletions rb/lib/selenium/webdriver/bidi/protocol/emulation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ class Emulation < Domain
landscape_secondary: 'landscape-secondary'
}.freeze

SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE = {
classic: 'classic',
overlay: 'overlay'
}.freeze

# @api private
# @see https://www.selenium.dev/documentation/warnings/bidi-implementation/
# @see https://w3c.github.io/webdriver-bidi/#cddl-type-emulationsetforcedcolorsmodethemeoverrideparameters
Expand Down Expand Up @@ -88,12 +93,12 @@ class SetGeolocationOverrideParameters < Serialization::Union
# @see https://www.selenium.dev/documentation/warnings/bidi-implementation/
# @see https://w3c.github.io/webdriver-bidi/#cddl-type-emulationgeolocationcoordinates
GeolocationCoordinates = Serialization::Record.define(
latitude: {wire_key: 'latitude', primitive: 'integer'},
longitude: {wire_key: 'longitude', primitive: 'integer'},
latitude: {wire_key: 'latitude', primitive: 'number'},
longitude: {wire_key: 'longitude', primitive: 'number'},
accuracy: {wire_key: 'accuracy', required: false, primitive: 'number'},
altitude: {wire_key: 'altitude', required: false, nullable: true, primitive: 'number'},
altitude_accuracy: {wire_key: 'altitudeAccuracy', required: false, nullable: true, primitive: 'number'},
heading: {wire_key: 'heading', required: false, nullable: true, primitive: 'integer'},
heading: {wire_key: 'heading', required: false, nullable: true, primitive: 'number'},
speed: {wire_key: 'speed', required: false, nullable: true, primitive: 'number'}
)

Expand Down Expand Up @@ -185,7 +190,11 @@ class SetGeolocationOverrideParameters < Serialization::Union
# @see https://www.selenium.dev/documentation/warnings/bidi-implementation/
# @see https://w3c.github.io/webdriver-bidi/#cddl-type-emulationsetscrollbartypeoverrideparameters
SetScrollbarTypeOverrideParameters = Serialization::Record.define(
scrollbar_type: {wire_key: 'scrollbarType', nullable: true, primitive: 'string'},
scrollbar_type: {
wire_key: 'scrollbarType',
nullable: true,
enum: 'Emulation::SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE'
},
contexts: {wire_key: 'contexts', required: false, list: true},
user_contexts: {wire_key: 'userContexts', required: false, list: true}
)
Expand Down Expand Up @@ -319,6 +328,11 @@ def set_scrollbar_type_override(
contexts: Serialization::UNSET,
user_contexts: Serialization::UNSET
)
Serialization.validate!(
'scrollbarType',
scrollbar_type,
Emulation::SET_SCROLLBAR_TYPE_OVERRIDE_PARAMETERS_SCROLLBAR_TYPE
)
params = SetScrollbarTypeOverrideParameters.new(
scrollbar_type: scrollbar_type,
contexts: contexts,
Expand Down
12 changes: 6 additions & 6 deletions rb/lib/selenium/webdriver/bidi/protocol/input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@ class WheelSourceAction < Serialization::Union
button: {wire_key: 'button', primitive: 'integer'},
width: {wire_key: 'width', required: false, primitive: 'integer'},
height: {wire_key: 'height', required: false, primitive: 'integer'},
pressure: {wire_key: 'pressure', required: false, primitive: 'integer'},
tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'integer'},
pressure: {wire_key: 'pressure', required: false, primitive: 'number'},
tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'number'},
twist: {wire_key: 'twist', required: false, primitive: 'integer'},
altitude_angle: {wire_key: 'altitudeAngle', required: false, primitive: 'number'},
azimuth_angle: {wire_key: 'azimuthAngle', required: false, primitive: 'number'}
Expand All @@ -215,8 +215,8 @@ class WheelSourceAction < Serialization::Union
origin: {wire_key: 'origin', required: false, ref: 'Input::Origin'},
width: {wire_key: 'width', required: false, primitive: 'integer'},
height: {wire_key: 'height', required: false, primitive: 'integer'},
pressure: {wire_key: 'pressure', required: false, primitive: 'integer'},
tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'integer'},
pressure: {wire_key: 'pressure', required: false, primitive: 'number'},
tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'number'},
twist: {wire_key: 'twist', required: false, primitive: 'integer'},
altitude_angle: {wire_key: 'altitudeAngle', required: false, primitive: 'number'},
azimuth_angle: {wire_key: 'azimuthAngle', required: false, primitive: 'number'}
Expand All @@ -241,8 +241,8 @@ class WheelSourceAction < Serialization::Union
PointerCommonProperties = Serialization::Record.define(
width: {wire_key: 'width', required: false, primitive: 'integer'},
height: {wire_key: 'height', required: false, primitive: 'integer'},
pressure: {wire_key: 'pressure', required: false, primitive: 'integer'},
tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'integer'},
pressure: {wire_key: 'pressure', required: false, primitive: 'number'},
tangential_pressure: {wire_key: 'tangentialPressure', required: false, primitive: 'number'},
twist: {wire_key: 'twist', required: false, primitive: 'integer'},
altitude_angle: {wire_key: 'altitudeAngle', required: false, primitive: 'number'},
azimuth_angle: {wire_key: 'azimuthAngle', required: false, primitive: 'number'}
Expand Down
32 changes: 26 additions & 6 deletions rb/lib/selenium/webdriver/bidi/serialization/record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,31 @@ 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), 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+.
# other value), a primitive field must be the matching Ruby type, 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/primitive/enum are checked
# separately in +wire_value+/+read+.
def validate_values(attributes)
fields.each do |f|
value = attributes[f.name]
raise ::ArgumentError, "#{name}##{f.name} is required" if UNSET.equal?(value) && f.required
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
validate_present(f, value)
end
end

# 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).
def validate_present(field, value)
validate_const(field, value)
check_outbound_shape(field, value)
check_outbound_primitive(field, value) unless field.list
Serialization.validate!("#{name}##{field.name}", value, Protocol.const_get(field.enum)) if field.enum
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 All @@ -137,6 +146,17 @@ def check_outbound_shape(field, value)
raise ::ArgumentError, "#{name}##{field.name} expected #{kind}, got #{value.inspect}"
end

# Outbound mirror of check_primitive: a primitive-typed arg (`string`/`integer`/…) must be
# the matching Ruby type, so a caller mistake (a string width, a float count) is a local
# ArgumentError here rather than a rejection the browser reports a round-trip later. A field
# with no primitive descriptor (enum, ref, opaque) passes; lists are skipped, as inbound does.
def check_outbound_primitive(field, value)
expected = PRIMITIVE_TYPES[field.primitive]
return if expected.nil? || expected.any? { |type| value.is_a?(type) }

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

def fixed?(field)
!UNSET.equal?(field.fixed)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ module Selenium
class ImageFormat < ::Selenium::WebDriver::BiDi::Serialization::Record
attr_reader type: String
attr_reader quality: untyped
def self.new: (type: String, ?quality: Integer) -> instance
def self.new: (type: String, ?quality: Numeric) -> instance
end

class ClipRectangle < ::Selenium::WebDriver::BiDi::Serialization::Union
Expand Down
Loading
Loading