selenium-webdriver 4.45.0 → 4.46.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.
- checksums.yaml +4 -4
- data/CHANGES +7 -0
- data/bin/linux/selenium-manager +0 -0
- data/bin/macos/selenium-manager +0 -0
- data/bin/windows/selenium-manager.exe +0 -0
- data/lib/selenium/webdriver/bidi/protocol/bluetooth.rb +465 -0
- data/lib/selenium/webdriver/bidi/protocol/browser.rb +222 -0
- data/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +694 -0
- data/lib/selenium/webdriver/bidi/protocol/domain.rb +40 -0
- data/lib/selenium/webdriver/bidi/protocol/emulation.rb +334 -0
- data/lib/selenium/webdriver/bidi/protocol/input.rb +281 -0
- data/lib/selenium/webdriver/bidi/protocol/log.rb +104 -0
- data/lib/selenium/webdriver/bidi/protocol/network.rb +637 -0
- data/lib/selenium/webdriver/bidi/protocol/permissions.rb +73 -0
- data/lib/selenium/webdriver/bidi/protocol/script.rb +874 -0
- data/lib/selenium/webdriver/bidi/protocol/session.rb +241 -0
- data/lib/selenium/webdriver/bidi/protocol/speculation.rb +56 -0
- data/lib/selenium/webdriver/bidi/protocol/storage.rb +157 -0
- data/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb +83 -0
- data/lib/selenium/webdriver/bidi/protocol/web_extension.rb +93 -0
- data/lib/selenium/webdriver/bidi/protocol.rb +39 -0
- data/lib/selenium/webdriver/bidi/serialization/record.rb +226 -0
- data/lib/selenium/webdriver/bidi/serialization/union.rb +114 -0
- data/lib/selenium/webdriver/bidi/serialization.rb +78 -0
- data/lib/selenium/webdriver/bidi/support/bidi_generate.rb +936 -0
- data/lib/selenium/webdriver/bidi/support/check_generated.rb +55 -0
- data/lib/selenium/webdriver/bidi/transport.rb +52 -0
- data/lib/selenium/webdriver/bidi.rb +3 -0
- data/lib/selenium/webdriver/chrome/driver.rb +3 -3
- data/lib/selenium/webdriver/common/client_config.rb +97 -0
- data/lib/selenium/webdriver/common/driver.rb +2 -2
- data/lib/selenium/webdriver/common/local_driver.rb +15 -4
- data/lib/selenium/webdriver/common/proxy.rb +1 -1
- data/lib/selenium/webdriver/common.rb +1 -0
- data/lib/selenium/webdriver/edge/driver.rb +3 -3
- data/lib/selenium/webdriver/firefox/driver.rb +3 -3
- data/lib/selenium/webdriver/ie/driver.rb +3 -3
- data/lib/selenium/webdriver/remote/bidi_bridge.rb +6 -1
- data/lib/selenium/webdriver/remote/bridge.rb +8 -10
- data/lib/selenium/webdriver/remote/driver.rb +12 -4
- data/lib/selenium/webdriver/remote/http/common.rb +25 -12
- data/lib/selenium/webdriver/remote/http/default.rb +56 -39
- data/lib/selenium/webdriver/safari/driver.rb +3 -3
- data/lib/selenium/webdriver/version.rb +1 -1
- metadata +25 -2
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Licensed to the Software Freedom Conservancy (SFC) under one
|
|
4
|
+
# or more contributor license agreements. See the NOTICE file
|
|
5
|
+
# distributed with this work for additional information
|
|
6
|
+
# regarding copyright ownership. The SFC licenses this file
|
|
7
|
+
# to you under the Apache License, Version 2.0 (the
|
|
8
|
+
# "License"); you may not use this file except in compliance
|
|
9
|
+
# with the License. You may obtain a copy of the License at
|
|
10
|
+
#
|
|
11
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
#
|
|
13
|
+
# Unless required by applicable law or agreed to in writing,
|
|
14
|
+
# software distributed under the License is distributed on an
|
|
15
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
16
|
+
# KIND, either express or implied. See the License for the
|
|
17
|
+
# specific language governing permissions and limitations
|
|
18
|
+
# under the License.
|
|
19
|
+
|
|
20
|
+
require 'json'
|
|
21
|
+
require 'erb'
|
|
22
|
+
require 'fileutils'
|
|
23
|
+
|
|
24
|
+
# Generates Ruby WebDriver BiDi protocol modules from the shared, binding-neutral
|
|
25
|
+
# BiDi schema produced by the JavaScript generator (see PR #17700):
|
|
26
|
+
# //javascript/selenium-webdriver:create-bidi-src_schema -> bidi-schema.json
|
|
27
|
+
#
|
|
28
|
+
# The schema is already normalized (inline enums hoisted, unions canonicalized,
|
|
29
|
+
# group composition flattened, wire names and nullability preserved verbatim), so
|
|
30
|
+
# this generator is a straight projection into Ruby with no CDDL interpretation.
|
|
31
|
+
#
|
|
32
|
+
# Invoked via `bazel run //rb/lib/selenium/webdriver:bidi-generate`. Bazel passes
|
|
33
|
+
# the schema path (resolved through runfiles) plus the workspace-relative output
|
|
34
|
+
# directory as ARGV. Can also be run directly:
|
|
35
|
+
# ruby bidi_generate.rb schema.json output/dir
|
|
36
|
+
#
|
|
37
|
+
# @api private
|
|
38
|
+
module BiDiGenerate
|
|
39
|
+
# Companion to the generated `@api private` tags: the page explaining why the BiDi
|
|
40
|
+
# implementation layer is internal and what higher-level API to use instead (see #17628).
|
|
41
|
+
BIDI_DOC_URL = 'https://www.selenium.dev/documentation/warnings/bidi-implementation/'
|
|
42
|
+
|
|
43
|
+
# RuboCop's Layout/LineLength max; emitted Serialization::Record.define calls wrap to stay within it.
|
|
44
|
+
LINE_LIMIT = 120
|
|
45
|
+
|
|
46
|
+
# Ruby keywords that cannot be used as method names unquoted.
|
|
47
|
+
RUBY_RESERVED = %w[begin end rescue ensure raise return yield if unless while until for do
|
|
48
|
+
case when then class module def].freeze
|
|
49
|
+
|
|
50
|
+
def self.camel_to_snake(str)
|
|
51
|
+
str
|
|
52
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
53
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
54
|
+
.downcase
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.snake_to_class_name(snake)
|
|
58
|
+
snake.split('_').map(&:capitalize).join
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Local constant for a domain-scoped type: "script.LocalValue" -> "LocalValue".
|
|
62
|
+
# The first letter is capitalized so a lower-cased spec name (e.g.
|
|
63
|
+
# "permissions.setPermission") still yields a valid Ruby constant.
|
|
64
|
+
def self.type_class_name(type_name)
|
|
65
|
+
type_name.split('.', 2).last.sub(/\A[a-z]/, &:upcase)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Protocol-relative class path: "script.LocalValue" -> "Script::LocalValue".
|
|
69
|
+
def self.type_ruby_path(type_name)
|
|
70
|
+
domain = type_name.split('.', 2).first
|
|
71
|
+
"#{snake_to_class_name(camel_to_snake(domain))}::#{type_class_name(type_name)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Source literal for a discriminator/const value (string, boolean, or number).
|
|
75
|
+
def self.ruby_literal(value)
|
|
76
|
+
return 'nil' if value.nil?
|
|
77
|
+
|
|
78
|
+
value.is_a?(String) ? "'#{value}'" : value.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Renders `prefix(args)` on one line, or one argument per line when it would exceed
|
|
82
|
+
# LINE_LIMIT at the given indent. open/close default to parentheses (pass {} for a
|
|
83
|
+
# hash literal) so emitted calls and literals stay within RuboCop's length limit.
|
|
84
|
+
def self.wrap_call(prefix, args, indent, open: '(', close: ')')
|
|
85
|
+
one_line = "#{prefix}#{open}#{args.join(', ')}#{close}"
|
|
86
|
+
return one_line if args.empty? || indent + one_line.length <= LINE_LIMIT
|
|
87
|
+
|
|
88
|
+
pad = ' ' * (indent + 2)
|
|
89
|
+
"#{prefix}#{open}\n#{pad}#{args.join(",\n#{pad}")}\n#{' ' * indent}#{close}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Append underscore to avoid clashing with Ruby reserved keywords.
|
|
93
|
+
def self.safe_method_name(name)
|
|
94
|
+
RUBY_RESERVED.include?(name) ? "#{name}_" : name
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Object/Data methods a Data member name would shadow (breaking value semantics
|
|
98
|
+
# or reflection), e.g. a "method" field overriding Object#method.
|
|
99
|
+
RESERVED_FIELD_NAMES = (RUBY_RESERVED + %w[method hash class send dup clone freeze inspect
|
|
100
|
+
to_h to_s members with deconstruct deconstruct_keys
|
|
101
|
+
object_id tap itself then display
|
|
102
|
+
extensible extensions]).freeze
|
|
103
|
+
|
|
104
|
+
# Append underscore to a field name that would shadow a core method; the wire
|
|
105
|
+
# name is unaffected, only the Ruby reader is renamed.
|
|
106
|
+
def self.safe_field_name(name)
|
|
107
|
+
RESERVED_FIELD_NAMES.include?(name) ? "#{name}_" : name
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# SCREAMING_SNAKE constant name for an enum, matching the EVENTS map style
|
|
111
|
+
# (ReadinessState → READINESS_STATE).
|
|
112
|
+
def self.screaming_snake(camel)
|
|
113
|
+
camel_to_snake(camel).upcase
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Makes an RBS type admit nil, idempotently (an already-nilable or opaque type is left
|
|
117
|
+
# as-is). Applied to a field whose schema type is nullable, so its value type allows nil;
|
|
118
|
+
# keyword-optionality is expressed separately by the `?` prefix (see rbs_part / rbs_arg).
|
|
119
|
+
def self.rbs_nilable(type)
|
|
120
|
+
return type if type == 'untyped' || type == 'nil' || type.end_with?('?')
|
|
121
|
+
|
|
122
|
+
"#{type}?"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Domain-qualified path to an enum's frozen hash constant
|
|
126
|
+
# ("browsingContext.ReadinessState" → "BrowsingContext::READINESS_STATE"), so a
|
|
127
|
+
# generated command method can reference it for an outbound membership check.
|
|
128
|
+
def self.enum_const_path(type_name)
|
|
129
|
+
domain, local = type_name.split('.', 2)
|
|
130
|
+
"#{snake_to_class_name(camel_to_snake(domain))}::#{screaming_snake(local)}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# snake_case hash key for an enum value (only a label for the wire value it maps to).
|
|
134
|
+
# Preserves camelCase word boundaries (beforeRequestSent → before_request_sent), maps a
|
|
135
|
+
# leading minus to "neg" (-0 → neg0, -Infinity → neg_infinity; no underscore before a
|
|
136
|
+
# digit, so the key stays normalcase), and collapses other punctuation
|
|
137
|
+
# (dedicated-worker → dedicated_worker).
|
|
138
|
+
def self.enum_key(value)
|
|
139
|
+
camel_to_snake(value.to_s)
|
|
140
|
+
.sub(/\A-(?=\d)/, 'neg')
|
|
141
|
+
.sub(/\A-/, 'neg_')
|
|
142
|
+
.gsub(/[^a-z0-9]+/, '_')
|
|
143
|
+
.gsub(/\A_+|_+\z/, '')
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ruby_name is the snake_case keyword argument; wire_name is the exact key the
|
|
147
|
+
# protocol expects (baked verbatim from the schema, no runtime conversion). enum is
|
|
148
|
+
# the allowed-values constant path for an enum-typed param (nil otherwise).
|
|
149
|
+
Param = Struct.new(:ruby_name, :wire_name, :required, :enum, :rbs, keyword_init: true) do
|
|
150
|
+
# Optionals default to UNSET (omitted), so an explicit nil can still reach a
|
|
151
|
+
# nullable field as wire null.
|
|
152
|
+
def sig_part
|
|
153
|
+
required ? "#{ruby_name}:" : "#{ruby_name}: Serialization::UNSET"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def enum_check(indent)
|
|
157
|
+
return unless enum
|
|
158
|
+
|
|
159
|
+
BiDiGenerate.wrap_call('Serialization.validate!', ["'#{wire_name}'", ruby_name, enum], indent)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# An RBS keyword parameter carrying the param's value type. The `?` prefix marks the
|
|
163
|
+
# keyword omittable; the value type already carries the schema's nullability, so nil is
|
|
164
|
+
# admitted only for a nullable field (a non-nullable one rejects nil at construction).
|
|
165
|
+
def rbs_part
|
|
166
|
+
type = rbs || 'untyped'
|
|
167
|
+
required ? "#{ruby_name}: #{type}" : "?#{ruby_name}: #{type}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# params_class is the Parameters class the named args construct (nil for a no-arg
|
|
172
|
+
# command); union_params picks its variant via `.build` rather than `.new`. result_ref
|
|
173
|
+
# is the Protocol-relative result class path, or nil to return the raw hash.
|
|
174
|
+
Command = Struct.new(:wire_name, :method_name, :params, :result_ref, :params_class,
|
|
175
|
+
:union_params, keyword_init: true) do
|
|
176
|
+
def required_params = params.select(&:required)
|
|
177
|
+
def optional_params = params.reject(&:required)
|
|
178
|
+
def enum_checks(indent) = params.filter_map { |p| p.enum_check(indent) }
|
|
179
|
+
|
|
180
|
+
# `def name` or `def name(...)` — wrapped one argument per line when the signature
|
|
181
|
+
# would exceed the line limit.
|
|
182
|
+
def def_header(indent)
|
|
183
|
+
return "def #{method_name}" if params.empty?
|
|
184
|
+
|
|
185
|
+
BiDiGenerate.wrap_call("def #{method_name}", required_params.map(&:sig_part) + optional_params.map(&:sig_part),
|
|
186
|
+
indent)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The RBS method signature `(params) -> return` — the return is the typed result class
|
|
190
|
+
# when the command parses one, else `untyped`.
|
|
191
|
+
def rbs_signature
|
|
192
|
+
"(#{rbs_params}) -> #{rbs_return}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def rbs_params
|
|
196
|
+
(required_params.map(&:rbs_part) + optional_params.map(&:rbs_part)).join(', ')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def rbs_return
|
|
200
|
+
result_ref ? "::Selenium::WebDriver::BiDi::Protocol::#{result_ref}" : 'untyped'
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The `params = …` line built before the execute call, or nil for a no-arg command. A
|
|
204
|
+
# record builds its Parameters object and a union dispatches via `.build` (whose typed
|
|
205
|
+
# as_json emits explicit null where a flat hash through Transport could not). Wrapped
|
|
206
|
+
# one entry per line when long, so it (and the short execute call) stay within the limit.
|
|
207
|
+
def params_assignment(indent)
|
|
208
|
+
return nil if params.empty?
|
|
209
|
+
|
|
210
|
+
kwargs = params.map { |p| "#{p.ruby_name}: #{p.ruby_name}" }
|
|
211
|
+
BiDiGenerate.wrap_call("params = #{params_class}.#{union_params ? 'build' : 'new'}", kwargs, indent)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# `@transport.execute(cmd:[, params: params][, result:])`. The result type is
|
|
215
|
+
# referenced directly (resolved lazily in the method body, and unambiguous within
|
|
216
|
+
# Protocol). Params, when present, are the `params` local built above.
|
|
217
|
+
def execute_call(indent)
|
|
218
|
+
args = ["cmd: '#{wire_name}'"]
|
|
219
|
+
args << 'params: params' unless params.empty?
|
|
220
|
+
args << "result: #{result_ref}" if result_ref
|
|
221
|
+
BiDiGenerate.wrap_call('execute', args, indent)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# payload_ref is the Protocol-relative class the event's params parse into (nil when
|
|
226
|
+
# non-structured, dispatched raw) — the inbound counterpart to a command's result_ref.
|
|
227
|
+
Event = Struct.new(:wire_name, :event_name, :payload_ref, keyword_init: true) do
|
|
228
|
+
# An EVENT_TYPES entry mapping the wire method to the type its params parse into.
|
|
229
|
+
def type_entry = "'#{wire_name}' => #{payload_ref || 'nil'}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# constant_name is the SCREAMING_SNAKE hash name; pairs are [symbol_key, wire_value] tuples.
|
|
233
|
+
Enum = Struct.new(:constant_name, :pairs, keyword_init: true)
|
|
234
|
+
|
|
235
|
+
# ref is the Protocol-relative class path for a nested structured field (nil
|
|
236
|
+
# for a scalar/opaque field); list wraps it in an array. wire_key is the exact
|
|
237
|
+
# JSON payload key (the schema's `wire` name, baked verbatim).
|
|
238
|
+
FieldIR = Struct.new(:ruby_name, :wire_key, :required, :nullable, :ref, :list, :enum, :primitive, :rbs,
|
|
239
|
+
keyword_init: true) do
|
|
240
|
+
# A `Serialization::Record.define` spec entry: `name: 'jsonKey'` shorthand, or
|
|
241
|
+
# `name: {wire_key:, …}` when the field carries JSON facts beyond its name.
|
|
242
|
+
# enum carries the allowed-values constant path, validated at construction.
|
|
243
|
+
def spec_entry(indent = 0)
|
|
244
|
+
meta = []
|
|
245
|
+
meta << 'required: false' unless required
|
|
246
|
+
meta << 'nullable: true' if nullable
|
|
247
|
+
meta << "ref: '#{ref}'" if ref
|
|
248
|
+
meta << 'list: true' if list
|
|
249
|
+
meta << "enum: '#{enum}'" if enum
|
|
250
|
+
meta << "primitive: '#{primitive}'" if primitive
|
|
251
|
+
return "#{ruby_name}: '#{wire_key}'" if meta.empty?
|
|
252
|
+
|
|
253
|
+
meta.unshift("wire_key: '#{wire_key}'")
|
|
254
|
+
BiDiGenerate.wrap_call("#{ruby_name}: ", meta, indent, open: '{', close: '}')
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The `self.new` keyword for this field — a user-supplied input carrying the field's
|
|
258
|
+
# value type. The `?` prefix marks the field omittable; its value type already carries
|
|
259
|
+
# the schema's nullability, so nil is admitted only for a nullable field.
|
|
260
|
+
def rbs_arg
|
|
261
|
+
required ? "#{ruby_name}: #{rbs}" : "?#{ruby_name}: #{rbs}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The `attr_reader` type. A present value is `rbs`; an omitted optional reads back
|
|
265
|
+
# the UNSET sentinel, which a value type can't capture, so optionals stay `untyped`.
|
|
266
|
+
def rbs_reader
|
|
267
|
+
"#{ruby_name}: #{required ? rbs : 'untyped'}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A generated immutable value type (a Serialization::Record.define(...) class). discriminator is the
|
|
272
|
+
# baked variant tag {ruby_name:, wire:, value:} or nil; schema_name/synthetic/owner/
|
|
273
|
+
# nested drive owner-nesting (see nest_synthetic).
|
|
274
|
+
TypeClass = Struct.new(:ruby_name, :fields, :discriminator, :extensible,
|
|
275
|
+
:schema_name, :synthetic, :owner, :label, :nested, keyword_init: true) do
|
|
276
|
+
def union? = false
|
|
277
|
+
def nested_types = nested || []
|
|
278
|
+
|
|
279
|
+
# Keyword arguments for `Serialization::Record.define(...)`: the fixed discriminator member
|
|
280
|
+
# first, then the fields, then the extensible flag.
|
|
281
|
+
def define_entries(entry_indent)
|
|
282
|
+
entries = []
|
|
283
|
+
entries << discriminator_entry if discriminator
|
|
284
|
+
entries.concat(fields.map { |f| f.spec_entry(entry_indent) })
|
|
285
|
+
entries << 'extensible: true' if extensible
|
|
286
|
+
entries
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# `Name = Serialization::Record.define(...)` as one line when it fits within the line limit at the
|
|
290
|
+
# given indent, else wrapped one entry per line — so the emitted source stays inside
|
|
291
|
+
# RuboCop's length limit without a per-file exception. Entries render at indent + 2, the
|
|
292
|
+
# indent a long field hash wraps itself against.
|
|
293
|
+
def define_assignment(name, indent)
|
|
294
|
+
BiDiGenerate.wrap_call("#{name} = Serialization::Record.define", define_entries(indent + 2), indent)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def discriminator_entry
|
|
298
|
+
literal = BiDiGenerate.ruby_literal(discriminator[:value])
|
|
299
|
+
if discriminator[:wire] == discriminator[:ruby_name].to_s
|
|
300
|
+
"#{discriminator[:ruby_name]}: {fixed: #{literal}}"
|
|
301
|
+
else
|
|
302
|
+
"#{discriminator[:ruby_name]}: {wire_key: '#{discriminator[:wire]}', fixed: #{literal}}"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Every Data member gets a typed `attr_reader`: the baked discriminator (typed to its
|
|
307
|
+
# const), each field (typed when required; UNSET-bearing optionals stay untyped), then
|
|
308
|
+
# the extensible passthrough.
|
|
309
|
+
def rbs_readers
|
|
310
|
+
readers = []
|
|
311
|
+
readers << "#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
|
|
312
|
+
readers.concat(fields.map(&:rbs_reader))
|
|
313
|
+
readers << 'extensions: Hash[String, untyped]' if extensible
|
|
314
|
+
readers
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The keyword arguments `self.new` accepts: each constructable field with its value
|
|
318
|
+
# type, plus the optional extensions bag. The fixed discriminator is baked, so its
|
|
319
|
+
# value is ignored — but the lenient `**kwargs` constructor still accepts it (and a
|
|
320
|
+
# command method passes it through), so it is advertised as an optional keyword typed
|
|
321
|
+
# to its const.
|
|
322
|
+
def rbs_new_args
|
|
323
|
+
parts = []
|
|
324
|
+
parts << "?#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
|
|
325
|
+
parts.concat(fields.map(&:rbs_arg))
|
|
326
|
+
parts << '?extensions: untyped' if extensible
|
|
327
|
+
parts.join(', ')
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# mode is :value (matched by discriminator), :fallback (the no-tag variant), or
|
|
332
|
+
# :presence (selected when its required wire keys are all present).
|
|
333
|
+
VariantIR = Struct.new(:mode, :value, :ref, :requires, keyword_init: true) do
|
|
334
|
+
# A string tag becomes an idiomatic symbol key; a bool/number tag stays a literal.
|
|
335
|
+
def symbolic? = value.is_a?(::String)
|
|
336
|
+
|
|
337
|
+
# The variant table entry: `sym: 'Ref'` for a string tag, else `true => 'Ref'`.
|
|
338
|
+
def variant_entry
|
|
339
|
+
key = symbolic? ? "#{BiDiGenerate.enum_key(value)}:" : "#{BiDiGenerate.ruby_literal(value)} =>"
|
|
340
|
+
"#{key} '#{ref}'"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# `sym: 'wireToken'` feeding the union's inbound wire->symbol map (string tags only).
|
|
344
|
+
def discriminator_pair
|
|
345
|
+
"#{BiDiGenerate.enum_key(value)}: '#{value}'" if symbolic?
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# A generated discriminated union (< Serialization::Union, resolved by lexical scope).
|
|
350
|
+
# nested holds its synthetic variant records (see nest_synthetic).
|
|
351
|
+
UnionClass = Struct.new(:ruby_name, :discriminator_wire, :variants, :schema_name, :nested, keyword_init: true) do
|
|
352
|
+
def union? = true
|
|
353
|
+
def value_variants = variants.select { |v| v.mode == :value }
|
|
354
|
+
def presence_variants = variants.select { |v| v.mode == :presence }
|
|
355
|
+
def fallback_variant = variants.find { |v| v.mode == :fallback }
|
|
356
|
+
def nested_types = nested || []
|
|
357
|
+
|
|
358
|
+
# `discriminator 'wire'`, or `discriminator 'wire', {sym: 'token', …}` (wrapped when
|
|
359
|
+
# long) carrying the inbound wire->symbol map for string-tagged variants.
|
|
360
|
+
def discriminator_decl(indent)
|
|
361
|
+
pairs = value_variants.filter_map(&:discriminator_pair)
|
|
362
|
+
head = "discriminator '#{discriminator_wire}'"
|
|
363
|
+
return head if pairs.empty?
|
|
364
|
+
|
|
365
|
+
BiDiGenerate.wrap_call("#{head}, ", pairs, indent, open: '{', close: '}')
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, keyword_init: true)
|
|
370
|
+
|
|
371
|
+
class Schema
|
|
372
|
+
def initialize(schema)
|
|
373
|
+
@types = schema['types']
|
|
374
|
+
@commands = schema['commands']
|
|
375
|
+
@events = schema['events']
|
|
376
|
+
promote_command_params_records!
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A command written in CDDL map form carries its params as an *inline* object (rather
|
|
380
|
+
# than the usual group form referencing a named params type). The projector links the
|
|
381
|
+
# command to those params, but hoists them into a synthetic record owned by the
|
|
382
|
+
# command's message envelope. That envelope is suppressed (Transport forms it), so the
|
|
383
|
+
# synthetic params record would never be emitted even though the command's params ref
|
|
384
|
+
# points straight at it. Promote it to a top-level domain record so the generator emits
|
|
385
|
+
# and references it like any other params type. Today this is exactly
|
|
386
|
+
# `userAgentClientHints.setClientHintsOverride`.
|
|
387
|
+
def promote_command_params_records!
|
|
388
|
+
@commands.each do |cmd|
|
|
389
|
+
ref = cmd.dig('params', 'ref')
|
|
390
|
+
next unless ref
|
|
391
|
+
|
|
392
|
+
type = @types[ref]
|
|
393
|
+
promote_to_domain_type!(ref) if type && envelope_synthetic?(type)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Strip the synthetic/owner/label tags so a lifted-out type emits as a top-level
|
|
398
|
+
# domain record instead of nesting under its (suppressed) envelope.
|
|
399
|
+
def promote_to_domain_type!(name)
|
|
400
|
+
type = @types[name]
|
|
401
|
+
type&.delete('synthetic')
|
|
402
|
+
type&.delete('owner')
|
|
403
|
+
type&.delete('label')
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Domains that carry a command or event each become one generated module.
|
|
407
|
+
def domains
|
|
408
|
+
(@commands + @events).map { |entry| entry['domain'] }.uniq
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def commands_for(domain)
|
|
412
|
+
@commands.select { |c| c['domain'] == domain }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def type_kind(ref)
|
|
416
|
+
@types[ref]&.fetch('kind', nil)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def events_for(domain)
|
|
420
|
+
@events.select { |e| e['domain'] == domain }
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Flat params for a command: the record's fields, or — for a union of
|
|
424
|
+
# records — the merged superset of variant fields. Returns [] for commands
|
|
425
|
+
# with no params, or nil when params can't be flattened (alias, or a union
|
|
426
|
+
# whose variants aren't all records) so the caller forwards verbatim.
|
|
427
|
+
def params_for(params_ref)
|
|
428
|
+
return [] unless params_ref
|
|
429
|
+
|
|
430
|
+
type = @types[params_ref['ref']]
|
|
431
|
+
return nil unless type
|
|
432
|
+
|
|
433
|
+
case type['kind']
|
|
434
|
+
when 'record' then record_params(type['fields'])
|
|
435
|
+
when 'union' then union_params(type, params_ref['ref'])
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Enum types declared under "<domain>." become nested constant modules.
|
|
440
|
+
def enums_for(domain)
|
|
441
|
+
@types.filter_map do |name, type|
|
|
442
|
+
next unless type['kind'] == 'enum'
|
|
443
|
+
next unless name.start_with?("#{domain}.")
|
|
444
|
+
|
|
445
|
+
pairs = type['values'].map { |v| [BiDiGenerate.enum_key(v), v.to_s] }
|
|
446
|
+
Enum.new(constant_name: BiDiGenerate.screaming_snake(name.sub("#{domain}.", '')), pairs: pairs)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Structured value classes (records + discriminated unions) declared under
|
|
451
|
+
# "<domain>." Empty records are projector artifacts with nothing to carry, so
|
|
452
|
+
# they stay opaque hashes; only non-empty records and unions become classes.
|
|
453
|
+
# Command/event message envelopes (the `{method, params}` wire wrapper) are
|
|
454
|
+
# skipped — Transport forms that envelope, so nothing references them.
|
|
455
|
+
def types_for(domain)
|
|
456
|
+
prefix = "#{domain}."
|
|
457
|
+
@types.filter_map do |name, type|
|
|
458
|
+
next unless name.start_with?(prefix)
|
|
459
|
+
|
|
460
|
+
case type['kind']
|
|
461
|
+
when 'record' then record_class(name, type) unless type['fields'].empty? || suppressed_record?(type)
|
|
462
|
+
when 'union' then union_class(name)
|
|
463
|
+
when 'alias' then union_class(name) if type['type'].key?('union')
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Records the generator deliberately does not emit: a message envelope, or a
|
|
469
|
+
# synthetic params record lifted out of one. Both are reachable only through the
|
|
470
|
+
# envelope, which Transport replaces — so nothing else references them.
|
|
471
|
+
def suppressed_record?(type)
|
|
472
|
+
message_envelope?(type) || envelope_synthetic?(type)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# A protocol message envelope is a record with a baked `method` discriminator
|
|
476
|
+
# (`{method: <const>, params: …}`) — the wire shape of a command/event message.
|
|
477
|
+
# No value type carries a const `method` field, so this is unambiguous.
|
|
478
|
+
def message_envelope?(type)
|
|
479
|
+
type['fields'].any? { |f| f['wire'] == 'method' && f['type'].key?('const') }
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# A synthetic record lifted out as an envelope's params (its owner is an envelope).
|
|
483
|
+
def envelope_synthetic?(type)
|
|
484
|
+
return false unless type['synthetic']
|
|
485
|
+
|
|
486
|
+
owner = @types[type['owner']]
|
|
487
|
+
owner && owner['kind'] == 'record' && message_envelope?(owner)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# The Protocol-relative class path a command result parses into, or nil when
|
|
491
|
+
# it is non-structured (or a bare list, returned raw).
|
|
492
|
+
def structured_ref(name)
|
|
493
|
+
resolved = resolve_named(name)
|
|
494
|
+
resolved[:list] ? nil : resolved[:ref]
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
private
|
|
498
|
+
|
|
499
|
+
def domain_path(name)
|
|
500
|
+
name.include?('.') ? ruby_path(name) : nil
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Class path, nesting a synthetic type under its owner as `Owner::Label` so a ref
|
|
504
|
+
# resolves to the same nested constant the type is emitted as.
|
|
505
|
+
def ruby_path(name)
|
|
506
|
+
type = @types[name]
|
|
507
|
+
return BiDiGenerate.type_ruby_path(name) unless type && type['synthetic']
|
|
508
|
+
|
|
509
|
+
"#{ruby_path(type['owner'])}::#{type['label']}"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Resolution for anything not modeled as a value type (scalar, enum, empty record).
|
|
513
|
+
# Frozen because it is shared across callers.
|
|
514
|
+
OPAQUE = {ref: nil, list: false, rbs: 'untyped'}.freeze
|
|
515
|
+
|
|
516
|
+
# Projects a schema type node to {ref:, list:, nullable:, rbs:}. Deriving the
|
|
517
|
+
# serialization facts and the RBS signature from one walk keeps them from drifting
|
|
518
|
+
# apart when the schema shape changes.
|
|
519
|
+
def resolve(node)
|
|
520
|
+
nullable = node['nullable'] ? true : false
|
|
521
|
+
if node.key?('list')
|
|
522
|
+
element = resolve(node['list'])
|
|
523
|
+
return {ref: element[:ref], list: true, nullable: nullable, rbs: nilable("Array[#{element[:rbs]}]", nullable)}
|
|
524
|
+
end
|
|
525
|
+
if node.key?('ref')
|
|
526
|
+
named = resolve_named(node['ref'])
|
|
527
|
+
return {ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable(named[:rbs], nullable)}
|
|
528
|
+
end
|
|
529
|
+
return resolve_union(node, nullable) if node.key?('union')
|
|
530
|
+
|
|
531
|
+
{ref: nil, list: false, nullable: nullable, primitive: checkable_primitive(node),
|
|
532
|
+
rbs: nilable(scalar_rbs(node), nullable)}
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# An inline union of one union-typed arm plus scalars (e.g. a MappingRemoteValue entry,
|
|
536
|
+
# RemoteValue / string) parses through that arm — its from_json returns a non-Hash value
|
|
537
|
+
# unchanged, so the scalar siblings pass through. Carry its ref so nested entries are typed;
|
|
538
|
+
# any other shape (a record arm, multiple structured arms, all scalars) stays opaque.
|
|
539
|
+
def resolve_union(node, nullable)
|
|
540
|
+
refs = node['union'].select { |arm| arm.key?('ref') }
|
|
541
|
+
opaque = {ref: nil, list: false, nullable: nullable, rbs: nilable('untyped', nullable)}
|
|
542
|
+
return opaque unless refs.one? && union_ref?(refs.first['ref'])
|
|
543
|
+
|
|
544
|
+
named = resolve_named(refs.first['ref'])
|
|
545
|
+
{ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable('untyped', nullable)}
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# True when a ref (following aliases) is a union — the only arm whose from_json tolerates a
|
|
549
|
+
# scalar sibling. A record arm would raise on one, so it is not carried.
|
|
550
|
+
def union_ref?(name)
|
|
551
|
+
type = @types[name]
|
|
552
|
+
return false unless type
|
|
553
|
+
return union_ref?(type['type']['ref']) if type['kind'] == 'alias' && type['type'].key?('ref')
|
|
554
|
+
|
|
555
|
+
type['kind'] == 'union'
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Resolves a named ref to the same {ref:, list:, rbs:} facts, transparently
|
|
559
|
+
# following aliases — including alias-to-list — so an element type behind an alias
|
|
560
|
+
# (e.g. script.ListLocalValue -> [script.LocalValue]) is preserved. Nullability is a
|
|
561
|
+
# property of the referencing node (applied by +resolve+), so it is not threaded
|
|
562
|
+
# here. seen guards against cyclic ref-aliases.
|
|
563
|
+
def resolve_named(name, seen = {})
|
|
564
|
+
return OPAQUE if name.nil? || seen[name]
|
|
565
|
+
|
|
566
|
+
seen[name] = true
|
|
567
|
+
type = @types[name]
|
|
568
|
+
return OPAQUE unless type
|
|
569
|
+
|
|
570
|
+
case type['kind']
|
|
571
|
+
when 'record' then type['fields'].empty? ? OPAQUE : named_type(name)
|
|
572
|
+
when 'union' then named_type(name)
|
|
573
|
+
when 'enum' then {ref: nil, list: false, rbs: 'Symbol'}
|
|
574
|
+
when 'alias' then resolve_named_alias(name, type['type'], seen)
|
|
575
|
+
else OPAQUE
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# A named structured type's serialization ref (nil for a dotless/global type, never
|
|
580
|
+
# emitted as a class) and its absolute RBS class path, derived independently so each
|
|
581
|
+
# output keeps its own treatment of dotless names.
|
|
582
|
+
def named_type(name)
|
|
583
|
+
{ref: domain_path(name), list: false, rbs: rbs_abs(ruby_path(name))}
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def resolve_named_alias(name, inner, seen)
|
|
587
|
+
return named_type(name) if inner.key?('union')
|
|
588
|
+
return resolve_named(inner['ref'], seen) if inner.key?('ref')
|
|
589
|
+
|
|
590
|
+
if inner.key?('list')
|
|
591
|
+
element = resolve(inner['list'])
|
|
592
|
+
return {ref: element[:ref], list: true, rbs: "Array[#{element[:rbs]}]"}
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
{ref: nil, list: false, rbs: scalar_rbs(inner)}
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def nilable(type, flag)
|
|
599
|
+
flag ? BiDiGenerate.rbs_nilable(type) : type
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def record_class(name, type)
|
|
603
|
+
const = type['fields'].find { |f| baked_discriminator?(f) }
|
|
604
|
+
discriminator = const && {ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(const['name'])),
|
|
605
|
+
wire: const['wire'], value: const['type']['const'],
|
|
606
|
+
rbs: rbs_const(const['type']['const'])}
|
|
607
|
+
fields = type['fields'].reject { |f| baked_discriminator?(f) }.map { |f| field_ir(f) }
|
|
608
|
+
TypeClass.new(ruby_name: BiDiGenerate.type_class_name(name), fields: fields,
|
|
609
|
+
discriminator: discriminator, extensible: type['extensible'] ? true : false,
|
|
610
|
+
schema_name: name, synthetic: type['synthetic'] ? true : false,
|
|
611
|
+
owner: type['owner'], label: type['label'])
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# A const field is a baked discriminator tag, unless it is also nullable: the spec's
|
|
615
|
+
# `literal | null` (browsingContext.setBypassCSP, emulation.setScriptingEnabled) is a
|
|
616
|
+
# settable value (the literal to set, null to clear), so it stays a normal field that
|
|
617
|
+
# can serialize null rather than a fixed tag that can only ever emit the literal.
|
|
618
|
+
def baked_discriminator?(field)
|
|
619
|
+
field['type'].key?('const') && !field['type']['nullable']
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def field_ir(field)
|
|
623
|
+
resolved = resolve(field['type'])
|
|
624
|
+
ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name']))
|
|
625
|
+
FieldIR.new(ruby_name: ruby_name, wire_key: field['wire'],
|
|
626
|
+
required: field['required'], nullable: resolved[:nullable],
|
|
627
|
+
ref: resolved[:ref], list: resolved[:list], enum: enum_const(field['type']),
|
|
628
|
+
primitive: resolved[:primitive], rbs: resolved[:rbs])
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def union_class(name)
|
|
632
|
+
type = @types[name]
|
|
633
|
+
# A first-class union carries the schema's authoritative dispatch `selector`
|
|
634
|
+
# (derived spec-faithfully, including null discriminators and the spec's choice
|
|
635
|
+
# order); consume it rather than re-deriving and silently depending on emit
|
|
636
|
+
# order. An alias-to-union (only input.Origin) has no selector — its const-string
|
|
637
|
+
# arms aren't first-class types — so it keeps the structural re-derivation.
|
|
638
|
+
type['kind'] == 'union' ? union_from_selector(name, type['selector']) : union_from_alias(name)
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Map a union `selector` to dispatch variants the template renders:
|
|
642
|
+
# { by, variants, default? } -> a discriminator table (value => ref), `default`
|
|
643
|
+
# as the fallback (it may itself be a union, which finishes the dispatch).
|
|
644
|
+
# { ordered: [{ ref, requires }] } -> presence rules in the spec's choice order.
|
|
645
|
+
# { correlated: true } -> resolved by request id, not the payload, so no payload
|
|
646
|
+
# dispatch. Unreachable here: every correlated union is a top-level result
|
|
647
|
+
# grouping, never domain-scoped, so it is never emitted as a class.
|
|
648
|
+
def union_from_selector(name, selector)
|
|
649
|
+
# A correlated union is resolved by request id, never the payload, so it carries
|
|
650
|
+
# no dispatch — it must never be emitted (every one is a top-level result
|
|
651
|
+
# grouping). Fail loudly if a future schema makes one domain-scoped rather than
|
|
652
|
+
# emit a Union whose every parse would raise.
|
|
653
|
+
if selector['correlated']
|
|
654
|
+
raise "correlated union #{name} must not be emitted (resolved by request id, not payload)"
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
variants = selector['by'] ? discriminated_variants(selector) : ordered_variants(selector)
|
|
658
|
+
raise "union #{name} selector yielded no dispatch variants" if variants.empty?
|
|
659
|
+
|
|
660
|
+
UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
|
|
661
|
+
discriminator_wire: selector['by'], variants: variants, schema_name: name)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def discriminated_variants(selector)
|
|
665
|
+
variants = selector['variants'].map do |variant|
|
|
666
|
+
VariantIR.new(mode: :value, value: variant['value'], ref: ruby_path(variant['ref']), requires: nil)
|
|
667
|
+
end
|
|
668
|
+
return variants unless selector['default']
|
|
669
|
+
|
|
670
|
+
variants << VariantIR.new(mode: :fallback, value: nil, ref: ruby_path(selector['default']), requires: nil)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def ordered_variants(selector)
|
|
674
|
+
(selector['ordered'] || []).map do |arm|
|
|
675
|
+
VariantIR.new(mode: :presence, value: nil, ref: ruby_path(arm['ref']), requires: arm['requires'])
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# The sole alias-union is input.Origin ("viewport" | "pointer" | ElementOrigin): a
|
|
680
|
+
# scalar-or-object union the object-payload selector model doesn't cover, so the
|
|
681
|
+
# projector leaves it an alias with no selector. Its object arm(s) carry a const
|
|
682
|
+
# discriminator; the bare-string arms need no dispatch (Union.from_json returns a
|
|
683
|
+
# non-Hash payload unchanged). So dispatch the ref arms by their const tag.
|
|
684
|
+
def union_from_alias(name)
|
|
685
|
+
consts = @types[name]['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref|
|
|
686
|
+
const = @types[ref]['fields'].find { |f| f['type'].key?('const') }
|
|
687
|
+
const || raise("alias-union #{name} arm #{ref} has no const discriminator to dispatch on")
|
|
688
|
+
[ref, const]
|
|
689
|
+
end
|
|
690
|
+
variants = consts.map do |ref, const|
|
|
691
|
+
VariantIR.new(mode: :value, value: const['type']['const'], ref: ruby_path(ref), requires: nil)
|
|
692
|
+
end
|
|
693
|
+
UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
|
|
694
|
+
discriminator_wire: consts.values.first['wire'], variants: variants, schema_name: name)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def record_params(fields)
|
|
698
|
+
fields.map do |field|
|
|
699
|
+
Param.new(
|
|
700
|
+
ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
|
|
701
|
+
wire_name: field['wire'],
|
|
702
|
+
required: field['required'],
|
|
703
|
+
enum: enum_const(field['type']),
|
|
704
|
+
rbs: rbs_type(field['type'])
|
|
705
|
+
)
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def rbs_type(node)
|
|
710
|
+
resolve(node)[:rbs]
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Every primitive the projector can emit maps to an RBS type. `unknown` is intentionally
|
|
714
|
+
# absent — the projector rejects it (an unhandled CDDL construct fails the build), so it
|
|
715
|
+
# never reaches here; any other unlisted primitive fails generation at scalar_rbs rather
|
|
716
|
+
# than slipping through as untyped.
|
|
717
|
+
PRIMITIVE_RBS = {
|
|
718
|
+
'string' => 'String', 'number' => 'Numeric', 'integer' => 'Integer', 'boolean' => 'bool', 'null' => 'nil'
|
|
719
|
+
}.freeze
|
|
720
|
+
|
|
721
|
+
# The scalar primitives that carry an inbound type-check. A field with no primitive (a ref,
|
|
722
|
+
# const, or opaque value) gets no descriptor and is left unchecked — lenient, so a missed
|
|
723
|
+
# check fails open rather than a wrong strict default rejecting valid data.
|
|
724
|
+
CHECKABLE_PRIMITIVES = %w[string number integer boolean].freeze
|
|
725
|
+
|
|
726
|
+
def checkable_primitive(node)
|
|
727
|
+
node['primitive'] if node.key?('primitive') && CHECKABLE_PRIMITIVES.include?(node['primitive'])
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# The leaf of +resolve+: the bare scalar type, before any nullable wrap. An alias's
|
|
731
|
+
# own nullable is intentionally left off — only the referencing node's is applied.
|
|
732
|
+
def scalar_rbs(node)
|
|
733
|
+
return PRIMITIVE_RBS.fetch(node['primitive']) if node.key?('primitive')
|
|
734
|
+
return rbs_const(node['const']) if node.key?('const')
|
|
735
|
+
|
|
736
|
+
'untyped'
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def rbs_const(value)
|
|
740
|
+
case value
|
|
741
|
+
when true, false then 'bool'
|
|
742
|
+
when ::String then 'String'
|
|
743
|
+
when ::Numeric then 'Numeric'
|
|
744
|
+
else 'untyped'
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def rbs_abs(path)
|
|
749
|
+
"::Selenium::WebDriver::BiDi::Protocol::#{path}"
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# The allowed-values constant path when a field (or a list's element) is an enum
|
|
753
|
+
# type, else nil. Union command-params skip this (their merged superset can blur a
|
|
754
|
+
# discriminator's const vs enum); only flat record params get the outbound check.
|
|
755
|
+
def enum_const(field_type)
|
|
756
|
+
ref = field_type['ref'] || field_type.dig('list', 'ref')
|
|
757
|
+
return unless ref && @types[ref] && @types[ref]['kind'] == 'enum'
|
|
758
|
+
|
|
759
|
+
BiDiGenerate.enum_const_path(ref)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# Merge a union's record variants into one flat param list for the command
|
|
763
|
+
# signature. A field is only required when every variant declares it required;
|
|
764
|
+
# variant-specific fields become optional. The command body dispatches these
|
|
765
|
+
# kwargs to the matching variant via `Union.build`, whose typed `as_json` handles
|
|
766
|
+
# null-vs-absent — so no nullable allowlist is needed.
|
|
767
|
+
def union_params(type, ref = nil)
|
|
768
|
+
variants = type['variants'].map { |variant_ref| @types[variant_ref] }
|
|
769
|
+
return nil unless variants.all? { |v| v && v['kind'] == 'record' }
|
|
770
|
+
|
|
771
|
+
selector = type['selector']
|
|
772
|
+
guard_union_dispatch_keys_simple!(selector, ref)
|
|
773
|
+
params = merged_params(variants.map { |v| v['fields'] })
|
|
774
|
+
annotate_discriminator_enum!(params, selector)
|
|
775
|
+
params
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# A discriminated union's `by` field is validated against the whole allowed set:
|
|
779
|
+
# the const values that tag each variant plus the default variant's own enum
|
|
780
|
+
# values (e.g. continueWithAuth.action = {provideCredentials} + {default, cancel}).
|
|
781
|
+
# That spans variants, so no single enum constant fits — emit an inline symbol=>wire
|
|
782
|
+
# hash so the check accepts the idiomatic symbol like every other enum.
|
|
783
|
+
# Boolean discriminators (handleRequestDevicePrompt.accept) need no membership check.
|
|
784
|
+
def annotate_discriminator_enum!(params, selector)
|
|
785
|
+
by = selector['by']
|
|
786
|
+
tagged = by ? selector['variants'].map { |v| v['value'] } : []
|
|
787
|
+
return unless !tagged.empty? && tagged.all?(String)
|
|
788
|
+
|
|
789
|
+
allowed = (tagged + default_variant_enum_values(selector, by)).uniq
|
|
790
|
+
pairs = allowed.map { |v| "#{BiDiGenerate.enum_key(v)}: '#{v}'" }
|
|
791
|
+
param = params.find { |p| p.wire_name == by }
|
|
792
|
+
return unless param
|
|
793
|
+
|
|
794
|
+
param.enum = "{#{pairs.join(', ')}}"
|
|
795
|
+
param.rbs = 'Symbol'
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def default_variant_enum_values(selector, by)
|
|
799
|
+
default = selector['default']
|
|
800
|
+
field = default && @types[default]['fields'].find { |f| f['wire'] == by }
|
|
801
|
+
ref = field && field['type']['ref']
|
|
802
|
+
ref && @types[ref] && @types[ref]['kind'] == 'enum' ? @types[ref]['values'] : []
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Merge variant field lists into one flat param superset. A field is required only
|
|
806
|
+
# when every variant declares it required; variant-specific fields become optional.
|
|
807
|
+
def merged_params(variant_fields)
|
|
808
|
+
all_fields = variant_fields.flatten
|
|
809
|
+
all_fields.map { |f| f['wire'] }.uniq.map do |wire|
|
|
810
|
+
field = all_fields.find { |f| f['wire'] == wire }
|
|
811
|
+
required = variant_fields.all? { |fields| fields.any? { |f| f['wire'] == wire && f['required'] } }
|
|
812
|
+
Param.new(ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
|
|
813
|
+
wire_name: wire, required: required, rbs: rbs_type(field['type']))
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# `Union.build` matches the command's kwargs to the selector's dispatch keys by
|
|
818
|
+
# symbol, which holds only while each dispatch wire key equals its ruby kwarg.
|
|
819
|
+
# Every current key is a single lowercase word; fail generation if a new one is
|
|
820
|
+
# camelCase so the outbound dispatch gets an explicit wire<->ruby mapping then.
|
|
821
|
+
def guard_union_dispatch_keys_simple!(selector, ref)
|
|
822
|
+
keys = selector['by'] ? [selector['by']] : (selector['ordered'] || []).flat_map { |arm| arm['requires'] }
|
|
823
|
+
camel = keys.reject { |k| BiDiGenerate.camel_to_snake(k) == k }
|
|
824
|
+
return if camel.empty?
|
|
825
|
+
|
|
826
|
+
raise "union command param #{ref} dispatches on non-snake wire key(s) #{camel.inspect}; " \
|
|
827
|
+
'Union.build matches kwargs to dispatch keys by symbol, so give the outbound ' \
|
|
828
|
+
'dispatch an explicit wire<->ruby mapping before shipping this.'
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Param kinds the named args can construct a Parameters object for (record fields,
|
|
833
|
+
# or a union dispatched to one of its variants); anything else forwards a raw hash.
|
|
834
|
+
PARAMS_CLASS_KINDS = %w[record union].freeze
|
|
835
|
+
|
|
836
|
+
def self.build_ir(schema)
|
|
837
|
+
schema.domains.map do |domain|
|
|
838
|
+
Module.new(
|
|
839
|
+
name: domain,
|
|
840
|
+
ruby_class: snake_to_class_name(camel_to_snake(domain)),
|
|
841
|
+
filename: camel_to_snake(domain),
|
|
842
|
+
commands: schema.commands_for(domain).map { |cmd| build_command(schema, cmd) },
|
|
843
|
+
events: schema.events_for(domain).map { |ev| build_event(schema, ev) },
|
|
844
|
+
enums: schema.enums_for(domain),
|
|
845
|
+
types: nest_synthetic(schema.types_for(domain))
|
|
846
|
+
)
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def self.build_command(schema, cmd)
|
|
851
|
+
params = schema.params_for(cmd['params'])
|
|
852
|
+
# A param that can't flatten to a typed object (alias or non-record union) would be
|
|
853
|
+
# silently dropped, so fail generation and handle that shape deliberately if it appears.
|
|
854
|
+
if cmd['params'] && params.nil?
|
|
855
|
+
raise "command #{cmd['method']} has params that cannot be expressed as a typed object"
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
params_ref = cmd['params'] && cmd['params']['ref']
|
|
859
|
+
params_kind = schema.type_kind(params_ref)
|
|
860
|
+
params_class = type_class_name(params_ref) if !params.empty? && PARAMS_CLASS_KINDS.include?(params_kind)
|
|
861
|
+
Command.new(
|
|
862
|
+
wire_name: cmd['method'],
|
|
863
|
+
method_name: safe_method_name(camel_to_snake(cmd['name'])),
|
|
864
|
+
params: params,
|
|
865
|
+
result_ref: cmd['result'] && schema.structured_ref(cmd['result']['ref']),
|
|
866
|
+
params_class: params_class,
|
|
867
|
+
union_params: params_kind == 'union'
|
|
868
|
+
)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def self.build_event(schema, event)
|
|
872
|
+
params = event['params']
|
|
873
|
+
payload_ref = params && params['ref'] && schema.structured_ref(params['ref'])
|
|
874
|
+
Event.new(wire_name: event['method'], event_name: camel_to_snake(event['name']), payload_ref: payload_ref)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# The projector tags lifted-out types with {synthetic, owner, label}. Emit each
|
|
878
|
+
# synthetic record inside its owner's class body under its bare label, so
|
|
879
|
+
# `Owner_Label` becomes the nested `Owner::Label` (refs resolve there via
|
|
880
|
+
# ruby_path). Synthetic enums stay domain-level. Raises on a missing owner.
|
|
881
|
+
def self.nest_synthetic(types)
|
|
882
|
+
index = types.to_h { |t| [t.schema_name, t] }
|
|
883
|
+
children = types.select { |t| !t.union? && t.synthetic }
|
|
884
|
+
children.each do |child|
|
|
885
|
+
owner = index[child.owner] ||
|
|
886
|
+
raise("synthetic type #{child.schema_name} has no emitted owner #{child.owner}")
|
|
887
|
+
owner.nested = (owner.nested || []) << child
|
|
888
|
+
child.ruby_name = child.label
|
|
889
|
+
end
|
|
890
|
+
types - children
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def self.render(mod, template_path)
|
|
894
|
+
ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def self.call(schema_path, output_dir)
|
|
898
|
+
raw = load_json(schema_path)
|
|
899
|
+
schema = Schema.new(raw)
|
|
900
|
+
modules = build_ir(schema)
|
|
901
|
+
|
|
902
|
+
emit(modules, output_dir, 'module.rb.erb', 'rb')
|
|
903
|
+
emit(modules, sig_dir(output_dir), 'module.rbs.erb', 'rbs')
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# Renders every module through one template and writes the result into target,
|
|
907
|
+
# one file per module. Used for both the Ruby source and its RBS signatures.
|
|
908
|
+
def self.emit(modules, output_dir, template, extension)
|
|
909
|
+
target = File.join(workspace_root, output_dir)
|
|
910
|
+
FileUtils.mkdir_p(target)
|
|
911
|
+
|
|
912
|
+
tmpl = File.join(File.dirname(__FILE__), 'templates', template)
|
|
913
|
+
modules.each do |mod|
|
|
914
|
+
path = File.join(target, "#{mod.filename}.#{extension}")
|
|
915
|
+
File.write(path, render(mod, tmpl))
|
|
916
|
+
warn "bidi-generate: wrote #{path}"
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
# The RBS signatures mirror the source tree under sig/ (the repo's convention),
|
|
921
|
+
# e.g. rb/lib/.../protocol -> rb/sig/lib/.../protocol.
|
|
922
|
+
def self.sig_dir(output_dir)
|
|
923
|
+
output_dir.sub(%r{(\A|/)lib/}, '\1sig/lib/')
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
private_class_method def self.load_json(path)
|
|
927
|
+
resolved = File.exist?(path) ? path : File.join(Dir.pwd, path)
|
|
928
|
+
JSON.parse(File.read(resolved))
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
private_class_method def self.workspace_root
|
|
932
|
+
ENV['BUILD_WORKSPACE_DIRECTORY'] || Dir.pwd
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
BiDiGenerate.call(*ARGV) if $PROGRAM_NAME == __FILE__
|