selenium-webdriver 4.35.0 → 4.47.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES +111 -0
  3. data/Gemfile +1 -4
  4. data/LICENSE +1 -1
  5. data/NOTICE +1 -1
  6. data/README.md +43 -23
  7. data/bin/linux/selenium-manager +0 -0
  8. data/bin/macos/selenium-manager +0 -0
  9. data/bin/selenium-manager-THIRD-PARTY-NOTICES.txt +5971 -0
  10. data/bin/selenium-manager.cdx.json +11629 -0
  11. data/bin/windows/selenium-manager.exe +0 -0
  12. data/lib/selenium/server.rb +30 -5
  13. data/lib/selenium/webdriver/atoms/findElements.js +272 -54
  14. data/lib/selenium/webdriver/atoms/getAttribute.js +152 -6
  15. data/lib/selenium/webdriver/atoms/isDisplayed.js +385 -28
  16. data/lib/selenium/webdriver/bidi/browser.rb +71 -0
  17. data/lib/selenium/webdriver/bidi/browsing_context.rb +3 -2
  18. data/lib/selenium/webdriver/bidi/{log/filter_by.rb → error.rb} +20 -16
  19. data/lib/selenium/webdriver/bidi/log_handler.rb +5 -0
  20. data/lib/selenium/webdriver/bidi/network/cookies.rb +13 -9
  21. data/lib/selenium/webdriver/bidi/network/credentials.rb +4 -0
  22. data/lib/selenium/webdriver/bidi/network/headers.rb +4 -0
  23. data/lib/selenium/webdriver/bidi/network/intercepted_auth.rb +4 -0
  24. data/lib/selenium/webdriver/bidi/network/intercepted_item.rb +4 -0
  25. data/lib/selenium/webdriver/bidi/network/intercepted_request.rb +20 -4
  26. data/lib/selenium/webdriver/bidi/network/intercepted_response.rb +24 -6
  27. data/lib/selenium/webdriver/bidi/network/url_pattern.rb +4 -0
  28. data/lib/selenium/webdriver/bidi/network.rb +18 -9
  29. data/lib/selenium/webdriver/bidi/protocol/bluetooth.rb +495 -0
  30. data/lib/selenium/webdriver/bidi/protocol/browser.rb +250 -0
  31. data/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +798 -0
  32. data/lib/selenium/webdriver/bidi/{log/base_log_entry.rb → protocol/domain.rb} +22 -11
  33. data/lib/selenium/webdriver/bidi/protocol/emulation.rb +448 -0
  34. data/lib/selenium/webdriver/bidi/protocol/error_code.rb +66 -0
  35. data/lib/selenium/webdriver/bidi/protocol/input.rb +354 -0
  36. data/lib/selenium/webdriver/bidi/protocol/log.rb +115 -0
  37. data/lib/selenium/webdriver/bidi/protocol/network.rb +720 -0
  38. data/lib/selenium/webdriver/bidi/protocol/permissions.rb +75 -0
  39. data/lib/selenium/webdriver/bidi/protocol/script.rb +1052 -0
  40. data/lib/selenium/webdriver/bidi/protocol/session.rb +286 -0
  41. data/lib/selenium/webdriver/bidi/protocol/speculation.rb +58 -0
  42. data/lib/selenium/webdriver/bidi/protocol/storage.rb +183 -0
  43. data/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb +88 -0
  44. data/lib/selenium/webdriver/bidi/protocol/web_extension.rb +132 -0
  45. data/lib/selenium/webdriver/bidi/protocol.rb +42 -0
  46. data/lib/selenium/webdriver/bidi/serialization/record.rb +402 -0
  47. data/lib/selenium/webdriver/bidi/serialization/union.rb +156 -0
  48. data/lib/selenium/webdriver/bidi/serialization.rb +90 -0
  49. data/lib/selenium/webdriver/bidi/session.rb +4 -0
  50. data/lib/selenium/webdriver/bidi/support/bidi_generate.rb +1420 -0
  51. data/lib/selenium/webdriver/bidi/support/check_generated.rb +63 -0
  52. data/lib/selenium/webdriver/bidi/transport.rb +57 -0
  53. data/lib/selenium/webdriver/bidi.rb +4 -1
  54. data/lib/selenium/webdriver/chrome/driver.rb +4 -3
  55. data/lib/selenium/webdriver/chrome/service.rb +13 -0
  56. data/lib/selenium/webdriver/chromium/driver.rb +0 -1
  57. data/lib/selenium/webdriver/chromium/options.rb +23 -1
  58. data/lib/selenium/webdriver/chromium/profile.rb +6 -0
  59. data/lib/selenium/webdriver/common/child_process.rb +2 -1
  60. data/lib/selenium/webdriver/common/client_config.rb +97 -0
  61. data/lib/selenium/webdriver/common/driver.rb +13 -7
  62. data/lib/selenium/webdriver/common/driver_extensions/has_log_events.rb +0 -7
  63. data/lib/selenium/webdriver/common/driver_extensions/has_network_interception.rb +0 -7
  64. data/lib/selenium/webdriver/common/driver_extensions/{has_bidi.rb → has_session_events.rb} +19 -7
  65. data/lib/selenium/webdriver/common/driver_finder.rb +35 -25
  66. data/lib/selenium/webdriver/common/error.rb +10 -3
  67. data/lib/selenium/webdriver/common/local_driver.rb +29 -12
  68. data/lib/selenium/webdriver/common/logger.rb +28 -0
  69. data/lib/selenium/webdriver/common/manager.rb +2 -0
  70. data/lib/selenium/webdriver/common/options.rb +41 -2
  71. data/lib/selenium/webdriver/common/platform.rb +1 -3
  72. data/lib/selenium/webdriver/common/proxy.rb +1 -9
  73. data/lib/selenium/webdriver/common/selenium_manager.rb +2 -1
  74. data/lib/selenium/webdriver/common/service.rb +4 -8
  75. data/lib/selenium/webdriver/common/service_manager.rb +36 -4
  76. data/lib/selenium/webdriver/common/socket_poller.rb +1 -1
  77. data/lib/selenium/webdriver/common/takes_screenshot.rb +1 -1
  78. data/lib/selenium/webdriver/common/virtual_authenticator/credential.rb +1 -1
  79. data/lib/selenium/webdriver/common/wait.rb +4 -1
  80. data/lib/selenium/webdriver/common/websocket_connection.rb +73 -37
  81. data/lib/selenium/webdriver/common.rb +2 -1
  82. data/lib/selenium/webdriver/devtools/response.rb +3 -3
  83. data/lib/selenium/webdriver/devtools.rb +1 -1
  84. data/lib/selenium/webdriver/edge/driver.rb +4 -3
  85. data/lib/selenium/webdriver/edge/service.rb +14 -0
  86. data/lib/selenium/webdriver/firefox/driver.rb +4 -6
  87. data/lib/selenium/webdriver/firefox/options.rb +19 -0
  88. data/lib/selenium/webdriver/firefox/profile.rb +13 -6
  89. data/lib/selenium/webdriver/firefox/service.rb +31 -2
  90. data/lib/selenium/webdriver/ie/driver.rb +4 -3
  91. data/lib/selenium/webdriver/ie/service.rb +10 -0
  92. data/lib/selenium/webdriver/remote/bidi_bridge.rb +44 -10
  93. data/lib/selenium/webdriver/remote/bridge.rb +20 -14
  94. data/lib/selenium/webdriver/remote/driver.rb +15 -4
  95. data/lib/selenium/webdriver/remote/features.rb +26 -1
  96. data/lib/selenium/webdriver/remote/http/common.rb +57 -12
  97. data/lib/selenium/webdriver/remote/http/curb.rb +6 -8
  98. data/lib/selenium/webdriver/remote/http/default.rb +56 -39
  99. data/lib/selenium/webdriver/safari/driver.rb +4 -3
  100. data/lib/selenium/webdriver/safari/options.rb +23 -4
  101. data/lib/selenium/webdriver/safari.rb +1 -6
  102. data/lib/selenium/webdriver/support/block_event_listener.rb +5 -1
  103. data/lib/selenium/webdriver/support/color.rb +14 -14
  104. data/lib/selenium/webdriver/support/event_firing_bridge.rb +5 -1
  105. data/lib/selenium/webdriver/support/guards/guard.rb +30 -14
  106. data/lib/selenium/webdriver/support/guards.rb +11 -4
  107. data/lib/selenium/webdriver/version.rb +1 -1
  108. data/lib/selenium/webdriver.rb +7 -2
  109. data/selenium-webdriver.gemspec +1 -1
  110. metadata +33 -11
  111. data/lib/selenium/webdriver/bidi/log/console_log_entry.rb +0 -35
  112. data/lib/selenium/webdriver/bidi/log/generic_log_entry.rb +0 -33
  113. data/lib/selenium/webdriver/bidi/log/javascript_log_entry.rb +0 -33
  114. data/lib/selenium/webdriver/bidi/log_inspector.rb +0 -147
@@ -0,0 +1,1420 @@
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
+ require_relative '../../../../../support/generated_note'
24
+
25
+ # Generates Ruby WebDriver BiDi protocol modules from the shared, binding-neutral
26
+ # BiDi schema produced by the JavaScript generator (see PR #17700):
27
+ # //javascript/selenium-webdriver:create-bidi-src_schema -> bidi-schema.json
28
+ #
29
+ # The schema is already normalized (inline enums hoisted, unions canonicalized,
30
+ # group composition flattened, wire names and nullability preserved verbatim), so
31
+ # this generator is a straight projection into Ruby with no CDDL interpretation.
32
+ #
33
+ # Invoked via `bazel run //rb/lib/selenium/webdriver:bidi-generate`. Bazel passes
34
+ # the schema path (resolved through runfiles) plus the workspace-relative output
35
+ # directory as ARGV, and supplies the shared generated-note text as a runfile, so
36
+ # this is not runnable directly from a source checkout.
37
+ #
38
+ # @api private
39
+ module BiDiGenerate
40
+ # Companion to the generated `@api private` tags: the page explaining why the BiDi
41
+ # implementation layer is internal and what higher-level API to use instead (see #17628).
42
+ BIDI_DOC_URL = 'https://www.selenium.dev/documentation/warnings/bidi-implementation/'
43
+
44
+ # RuboCop's Layout/LineLength max; emitted Serialization::Record.define calls wrap to stay within it.
45
+ LINE_LIMIT = 120
46
+
47
+ # Ruby keywords that cannot be used as method names unquoted.
48
+ RUBY_RESERVED = %w[begin end rescue ensure raise return yield if unless while until for do
49
+ case when then class module def].freeze
50
+
51
+ def self.camel_to_snake(str)
52
+ str
53
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
54
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
55
+ .downcase
56
+ end
57
+
58
+ def self.snake_to_class_name(snake)
59
+ snake.split('_').map(&:capitalize).join
60
+ end
61
+
62
+ # Local constant for a domain-scoped type: "script.LocalValue" -> "LocalValue".
63
+ # The first letter is capitalized so a lower-cased spec name (e.g.
64
+ # "permissions.setPermission") still yields a valid Ruby constant.
65
+ def self.type_class_name(type_name)
66
+ type_name.split('.', 2).last.sub(/\A[a-z]/, &:upcase)
67
+ end
68
+
69
+ # Protocol-relative class path: "script.LocalValue" -> "Script::LocalValue".
70
+ def self.type_ruby_path(type_name)
71
+ domain = type_name.split('.', 2).first
72
+ "#{snake_to_class_name(camel_to_snake(domain))}::#{type_class_name(type_name)}"
73
+ end
74
+
75
+ # Source literal for a discriminator/const value (string, boolean, or number).
76
+ def self.ruby_literal(value)
77
+ return 'nil' if value.nil?
78
+
79
+ value.is_a?(String) ? "'#{value}'" : value.to_s
80
+ end
81
+
82
+ # Renders `prefix(args)` on one line, or one argument per line when it would exceed
83
+ # LINE_LIMIT at the given indent. open/close default to parentheses (pass {} for a
84
+ # hash literal) so emitted calls and literals stay within RuboCop's length limit.
85
+ def self.wrap_call(prefix, args, indent, open: '(', close: ')')
86
+ one_line = "#{prefix}#{open}#{args.join(', ')}#{close}"
87
+ return one_line if args.empty? || indent + one_line.length <= LINE_LIMIT
88
+
89
+ pad = ' ' * (indent + 2)
90
+ "#{prefix}#{open}\n#{pad}#{args.join(",\n#{pad}")}\n#{' ' * indent}#{close}"
91
+ end
92
+
93
+ # Append underscore to avoid clashing with Ruby reserved keywords.
94
+ def self.safe_method_name(name)
95
+ RUBY_RESERVED.include?(name) ? "#{name}_" : name
96
+ end
97
+
98
+ # Object/Data methods a Data member name would shadow (breaking value semantics
99
+ # or reflection), e.g. a "method" field overriding Object#method.
100
+ RESERVED_FIELD_NAMES = (RUBY_RESERVED + %w[method hash class send dup clone freeze inspect
101
+ to_h to_s members with deconstruct deconstruct_keys
102
+ object_id tap itself then display
103
+ extensible extensions]).freeze
104
+
105
+ # Append underscore to a field name that would shadow a core method; the wire
106
+ # name is unaffected, only the Ruby reader is renamed.
107
+ def self.safe_field_name(name)
108
+ # A vendor-prefixed wire name carries a colon (moz:allowPrivateBrowsing); swap it
109
+ # for an underscore so the Ruby reader is a legal identifier. The wire key is kept.
110
+ name = name.tr(':', '_')
111
+ RESERVED_FIELD_NAMES.include?(name) ? "#{name}_" : name
112
+ end
113
+
114
+ # SCREAMING_SNAKE constant name for an enum, matching the EVENTS map style
115
+ # (ReadinessState → READINESS_STATE).
116
+ def self.screaming_snake(camel)
117
+ camel_to_snake(camel).upcase
118
+ end
119
+
120
+ # Makes an RBS type admit nil, idempotently (an already-nilable or opaque type is left
121
+ # as-is). Applied to a field whose schema type is nullable, so its value type allows nil;
122
+ # keyword-optionality is expressed separately by the `?` prefix (see rbs_part / rbs_arg).
123
+ def self.rbs_nilable(type)
124
+ return type if type == 'untyped' || type == 'nil' || type.end_with?('?')
125
+
126
+ "#{type}?"
127
+ end
128
+
129
+ # Domain-qualified path to an enum's frozen hash constant
130
+ # ("browsingContext.ReadinessState" → "BrowsingContext::READINESS_STATE"), so a
131
+ # generated command method can reference it for an outbound membership check.
132
+ def self.enum_const_path(type_name)
133
+ domain, local = type_name.split('.', 2)
134
+ "#{snake_to_class_name(camel_to_snake(domain))}::#{screaming_snake(local)}"
135
+ end
136
+
137
+ # snake_case hash key for an enum value (only a label for the wire value it maps to).
138
+ # Preserves camelCase word boundaries (beforeRequestSent → before_request_sent), maps a
139
+ # leading minus to "neg" (-0 → neg0, -Infinity → neg_infinity; no underscore before a
140
+ # digit, so the key stays normalcase), and collapses other punctuation
141
+ # (dedicated-worker → dedicated_worker).
142
+ def self.enum_key(value)
143
+ camel_to_snake(value.to_s)
144
+ .sub(/\A-(?=\d)/, 'neg')
145
+ .sub(/\A-/, 'neg_')
146
+ .gsub(/[^a-z0-9]+/, '_')
147
+ .gsub(/\A_+|_+\z/, '')
148
+ end
149
+
150
+ # ruby_name is the snake_case keyword argument; wire_name is the exact key the
151
+ # protocol expects (baked verbatim from the schema, no runtime conversion). enum is
152
+ # the allowed-values constant path for an enum-typed param (nil otherwise).
153
+ Param = Struct.new(:ruby_name, :wire_name, :required, :enum, :rbs, keyword_init: true) do
154
+ # Optionals default to UNSET (omitted), so an explicit nil can still reach a
155
+ # nullable field as wire null.
156
+ def sig_part
157
+ required ? "#{ruby_name}:" : "#{ruby_name}: Serialization::UNSET"
158
+ end
159
+
160
+ def enum_check(indent)
161
+ return unless enum
162
+
163
+ BiDiGenerate.wrap_call('Serialization.validate!', ["'#{wire_name}'", ruby_name, enum], indent)
164
+ end
165
+
166
+ # An RBS keyword parameter carrying the param's value type. The `?` prefix marks the
167
+ # keyword omittable; the value type already carries the schema's nullability, so nil is
168
+ # admitted only for a nullable field (a non-nullable one rejects nil at construction).
169
+ def rbs_part
170
+ type = rbs || 'untyped'
171
+ required ? "#{ruby_name}: #{type}" : "?#{ruby_name}: #{type}"
172
+ end
173
+ end
174
+
175
+ # params_class is the Parameters class the named args construct (nil for a no-arg
176
+ # command); union_params picks its variant via `.build` rather than `.new`. result_ref
177
+ # is the Protocol-relative result class path, or nil to return the raw hash.
178
+ Command = Struct.new(:wire_name, :method_name, :params, :result_ref, :params_class,
179
+ :union_params, :spec_href, keyword_init: true) do
180
+ def required_params = params.select(&:required)
181
+ def optional_params = params.reject(&:required)
182
+ def enum_checks(indent) = params.filter_map { |p| p.enum_check(indent) }
183
+
184
+ # `def name` or `def name(...)` — wrapped one argument per line when the signature
185
+ # would exceed the line limit.
186
+ def def_header(indent)
187
+ return "def #{method_name}" if params.empty?
188
+
189
+ BiDiGenerate.wrap_call("def #{method_name}", required_params.map(&:sig_part) + optional_params.map(&:sig_part),
190
+ indent)
191
+ end
192
+
193
+ # The RBS method signature `(params) -> return` — the return is the typed result class
194
+ # when the command parses one, else `untyped`.
195
+ def rbs_signature
196
+ "(#{rbs_params}) -> #{rbs_return}"
197
+ end
198
+
199
+ def rbs_params
200
+ (required_params.map(&:rbs_part) + optional_params.map(&:rbs_part)).join(', ')
201
+ end
202
+
203
+ def rbs_return
204
+ result_ref ? "::Selenium::WebDriver::BiDi::Protocol::#{result_ref}" : 'untyped'
205
+ end
206
+
207
+ # The `params = …` line built before the execute call, or nil for a no-arg command. A
208
+ # record builds its Parameters object and a union dispatches via `.build` (whose typed
209
+ # as_json emits explicit null where a flat hash through Transport could not). Wrapped
210
+ # one entry per line when long, so it (and the short execute call) stay within the limit.
211
+ def params_assignment(indent)
212
+ return nil if params.empty?
213
+
214
+ kwargs = params.map { |p| "#{p.ruby_name}: #{p.ruby_name}" }
215
+ BiDiGenerate.wrap_call("params = #{params_class}.#{union_params ? 'build' : 'new'}", kwargs, indent)
216
+ end
217
+
218
+ # `@transport.execute(cmd:[, params: params][, result:])`. The result type is
219
+ # referenced directly (resolved lazily in the method body, and unambiguous within
220
+ # Protocol). Params, when present, are the `params` local built above.
221
+ def execute_call(indent)
222
+ args = ["cmd: '#{wire_name}'"]
223
+ args << 'params: params' unless params.empty?
224
+ args << "result: #{result_ref}" if result_ref
225
+ BiDiGenerate.wrap_call('execute', args, indent)
226
+ end
227
+ end
228
+
229
+ # A browser-specific extension to a command, kept out of the shared class so a
230
+ # non-matching browser never sees it. shared_params are the base command's own
231
+ # (required) params, forwarded verbatim; vendor_params are the typed extra fields,
232
+ # composed into the extensible params record's passthrough bag under their exact
233
+ # wire keys. params_class/result_ref/wire_name mirror the base command.
234
+ VendorCommand = Struct.new(:method_name, :wire_name, :result_ref, :params_class,
235
+ :shared_params, :vendor_params, :spec_href, keyword_init: true) do
236
+ def def_header(indent)
237
+ BiDiGenerate.wrap_call("def #{method_name}", shared_params.map(&:sig_part) + vendor_params.map(&:sig_part),
238
+ indent)
239
+ end
240
+
241
+ # The full `def … end` method block, fully indented from `indent`. Optional vendor
242
+ # fields are placed into the passthrough bag only when set (UNSET stays omitted), so
243
+ # they serialize exactly like a field on the extensible record.
244
+ def render_lines(indent)
245
+ body = ' ' * (indent + 2)
246
+ [*doc_lines(' ' * indent), "#{' ' * indent}#{def_header(indent)}", *extensions_lines(body, indent),
247
+ params_line(body, indent), execute_line(body, indent), "#{' ' * indent}end"]
248
+ end
249
+
250
+ def doc_lines(pad)
251
+ lines = ["#{pad}# @api private", "#{pad}# @see #{BiDiGenerate::BIDI_DOC_URL}"]
252
+ lines << "#{pad}# @see #{spec_href}" if spec_href
253
+ lines
254
+ end
255
+
256
+ # The extensible passthrough bag, carrying each set vendor field under its exact wire key.
257
+ def extensions_lines(body, indent)
258
+ inner = ' ' * (indent + 4)
259
+ entries = vendor_params.map { |p| "#{inner}'#{p.wire_name}' => #{p.ruby_name}" }.join(",\n")
260
+ ["#{body}extensions = {", entries, "#{body}}.reject { |_, value| Serialization::UNSET.equal?(value) }"]
261
+ end
262
+
263
+ def params_line(body, indent)
264
+ kwargs = shared_params.map { |p| "#{p.ruby_name}: #{p.ruby_name}" } + ['extensions: extensions']
265
+ "#{body}#{BiDiGenerate.wrap_call("params = #{params_class}.new", kwargs, indent + 2)}"
266
+ end
267
+
268
+ def execute_line(body, indent)
269
+ args = ["cmd: '#{wire_name}'", 'params: params']
270
+ args << "result: #{result_ref}" if result_ref
271
+ "#{body}#{BiDiGenerate.wrap_call('execute', args, indent + 2)}"
272
+ end
273
+
274
+ def rbs_signature
275
+ params = (shared_params.map(&:rbs_part) + vendor_params.map(&:rbs_part)).join(', ')
276
+ ret = result_ref ? "::Selenium::WebDriver::BiDi::Protocol::#{result_ref}" : 'untyped'
277
+ "(#{params}) -> #{ret}"
278
+ end
279
+ end
280
+
281
+ # A namespaced group of browser-specific command extensions (e.g. Firefox's `moz:`
282
+ # fields), emitted as a subclass of the domain that overrides the extended commands.
283
+ # A subclass (rather than a runtime-mixed module) keeps the vendor signatures statically
284
+ # visible to type checkers, and is constructed directly (`<Name>.new(source)`) for a
285
+ # matching session — no factory or runtime mix-in.
286
+ VendorModule = Struct.new(:name, :namespace, :parent, :commands, keyword_init: true) do
287
+ def render(indent)
288
+ pad = ' ' * indent
289
+ lines = [
290
+ "#{pad}# @api private",
291
+ "#{pad}# #{namespace}: vendor variant of #{parent}, overriding commands with browser-specific params.",
292
+ "#{pad}# Construct #{name}.new(source) for a matching session; other sessions use #{parent}.",
293
+ "#{pad}class #{name} < #{parent}"
294
+ ]
295
+ commands.each_with_index do |cmd, index|
296
+ lines << '' unless index.zero?
297
+ lines.concat(cmd.render_lines(indent + 2))
298
+ end
299
+ lines << "#{pad}end"
300
+ lines.join("\n")
301
+ end
302
+ end
303
+
304
+ # payload_ref is the Protocol-relative class the event's params parse into (nil when
305
+ # non-structured, dispatched raw) — the inbound counterpart to a command's result_ref.
306
+ Event = Struct.new(:wire_name, :event_name, :payload_ref, keyword_init: true) do
307
+ # An EVENT_TYPES entry mapping the wire method to the type its params parse into.
308
+ def type_entry = "'#{wire_name}' => #{payload_ref || 'nil'}"
309
+ end
310
+
311
+ # constant_name is the SCREAMING_SNAKE hash name; pairs are [symbol_key, wire_value] tuples.
312
+ # spec_href links to the type's definition in the live spec (nil when the schema has none).
313
+ Enum = Struct.new(:constant_name, :pairs, :spec_href, keyword_init: true)
314
+
315
+ # The generated Protocol::ErrorCode module (filename 'error_code'): `codes` is the [wire, class_name]
316
+ # pairs in schema order (the full map); `new_classes` is the subset of class names the classic
317
+ # Error module does not already define (the ones whose RBS this file must declare). Rendered
318
+ # through the same emit/render path as the domain modules.
319
+ ErrorModule = Struct.new(:filename, :codes, :new_classes, keyword_init: true)
320
+
321
+ # ref is the Protocol-relative class path for a nested structured field (nil
322
+ # for a scalar/opaque field); list wraps it in an array. wire_key is the exact
323
+ # JSON payload key (the schema's `wire` name, baked verbatim).
324
+ FieldIR = Struct.new(:ruby_name, :wire_key, :required, :nullable, :ref, :list, :enum, :primitive, :scalar, :const,
325
+ :rbs, keyword_init: true) do
326
+ # A `Serialization::Record.define` spec entry: `name: 'jsonKey'` shorthand, or
327
+ # `name: {wire_key:, …}` when the field carries JSON facts beyond its name.
328
+ # enum carries the allowed-values constant path, validated at construction.
329
+ def spec_entry(indent = 0)
330
+ meta = value_facts
331
+ return "#{ruby_name}: '#{wire_key}'" if meta.empty?
332
+
333
+ meta.unshift("wire_key: '#{wire_key}'")
334
+ BiDiGenerate.wrap_call("#{ruby_name}: ", meta, indent, open: '{', close: '}')
335
+ end
336
+
337
+ # The JSON facts beyond the field's name, in the order Record.define reads them. A
338
+ # nullable const (`literal / null`) carries `const:` so the runtime rejects a value that
339
+ # is neither the literal nor null; `const.nil?` means the field has no const at all.
340
+ def value_facts
341
+ facts = []
342
+ facts << 'required: false' unless required
343
+ facts << 'nullable: true' if nullable
344
+ facts << "const: #{BiDiGenerate.ruby_literal(const)}" unless const.nil?
345
+ facts << "ref: '#{ref}'" if ref
346
+ facts << 'list: true' if list
347
+ facts << "scalar: #{scalar_literal}" if scalar
348
+ facts << "enum: '#{enum}'" if enum
349
+ facts << "primitive: '#{primitive}'" if primitive
350
+ facts
351
+ end
352
+
353
+ # The `scalar` primitive(s) a bare non-object wire value must match at a scalar-tolerant
354
+ # union position: a single primitive string, or an array when the union's scalar arms differ.
355
+ def scalar_literal
356
+ scalar.is_a?(::Array) ? "[#{scalar.map { |s| "'#{s}'" }.join(', ')}]" : "'#{scalar}'"
357
+ end
358
+
359
+ # The `self.new` keyword for this field — a user-supplied input carrying the field's
360
+ # value type. The `?` prefix marks the field omittable; its value type already carries
361
+ # the schema's nullability, so nil is admitted only for a nullable field.
362
+ def rbs_arg
363
+ required ? "#{ruby_name}: #{rbs}" : "?#{ruby_name}: #{rbs}"
364
+ end
365
+
366
+ # The `attr_reader` type. A present value is `rbs`; an omitted optional reads back
367
+ # the UNSET sentinel, which a value type can't capture, so optionals stay `untyped`.
368
+ def rbs_reader
369
+ "#{ruby_name}: #{required ? rbs : 'untyped'}"
370
+ end
371
+ end
372
+
373
+ # A generated immutable value type (a Serialization::Record.define(...) class). discriminator is the
374
+ # baked variant tag {ruby_name:, wire:, value:} or nil; schema_name/synthetic/owner/
375
+ # nested drive owner-nesting (see nest_synthetic). spec_href links to the type's
376
+ # definition in the live spec (nil when the schema has none, e.g. a synthetic type).
377
+ TypeClass = Struct.new(:ruby_name, :fields, :discriminator, :extensible, :schema_name, :synthetic,
378
+ :owner, :label, :nested, :spec_href, :outbound, :inbound, keyword_init: true) do
379
+ def union? = false
380
+ def nested_types = nested || []
381
+
382
+ # Keyword arguments for `Serialization::Record.define(...)`: the fixed discriminator member
383
+ # first, then the fields, then the extensible flag.
384
+ def define_entries(entry_indent)
385
+ entries = []
386
+ entries << discriminator_entry if discriminator
387
+ entries.concat(fields.map { |f| f.spec_entry(entry_indent) })
388
+ entries << 'extensible: true' if extensible
389
+ entries
390
+ end
391
+
392
+ # `Name = Serialization::Record.define(...)` as one line when it fits within the line limit at the
393
+ # given indent, else wrapped one entry per line — so the emitted source stays inside
394
+ # RuboCop's length limit without a per-file exception. Entries render at indent + 2, the
395
+ # indent a long field hash wraps itself against.
396
+ def define_assignment(name, indent)
397
+ BiDiGenerate.wrap_call("#{name} = Serialization::Record.define", define_entries(indent + 2), indent)
398
+ end
399
+
400
+ def discriminator_entry
401
+ literal = BiDiGenerate.ruby_literal(discriminator[:value])
402
+ if discriminator[:wire] == discriminator[:ruby_name].to_s
403
+ "#{discriminator[:ruby_name]}: {fixed: #{literal}}"
404
+ else
405
+ "#{discriminator[:ruby_name]}: {wire_key: '#{discriminator[:wire]}', fixed: #{literal}}"
406
+ end
407
+ end
408
+
409
+ # Every Data member gets a typed `attr_reader`: the baked discriminator (typed to its
410
+ # const), each field (typed when required; UNSET-bearing optionals stay untyped), then
411
+ # the extensible passthrough.
412
+ def rbs_readers
413
+ readers = []
414
+ readers << "#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
415
+ readers.concat(fields.map(&:rbs_reader))
416
+ readers << 'extensions: Hash[String, untyped]' if extensible
417
+ readers
418
+ end
419
+
420
+ # The keyword arguments `self.new` accepts: each constructable field with its value
421
+ # type, plus the optional extensions bag. The fixed discriminator is baked, so its
422
+ # value is ignored — but the lenient `**kwargs` constructor still accepts it (and a
423
+ # command method passes it through), so it is advertised as an optional keyword typed
424
+ # to its const.
425
+ def rbs_new_args
426
+ parts = []
427
+ parts << "?#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
428
+ parts.concat(fields.map(&:rbs_arg))
429
+ # Match the reader type and the extensible Record impl (which calls `merge!`/`empty?` on it),
430
+ # so a type checker rejects a non-Hash before it crashes at serialization.
431
+ parts << '?extensions: Hash[String, untyped]' if extensible
432
+ parts.join(', ')
433
+ end
434
+ end
435
+
436
+ # mode is :value (matched by discriminator), :fallback (the no-tag variant), or
437
+ # :presence (selected when its required wire keys are all present).
438
+ VariantIR = Struct.new(:mode, :value, :ref, :requires, keyword_init: true) do
439
+ # A string tag becomes an idiomatic symbol key; a bool/number tag stays a literal.
440
+ def symbolic? = value.is_a?(::String)
441
+
442
+ # The variant table entry: `sym: 'Ref'` for a string tag, else `true => 'Ref'`.
443
+ def variant_entry
444
+ key = symbolic? ? "#{BiDiGenerate.enum_key(value)}:" : "#{BiDiGenerate.ruby_literal(value)} =>"
445
+ "#{key} '#{ref}'"
446
+ end
447
+
448
+ # `sym: 'wireToken'` feeding the union's inbound wire->symbol map (string tags only).
449
+ def discriminator_pair
450
+ "#{BiDiGenerate.enum_key(value)}: '#{value}'" if symbolic?
451
+ end
452
+ end
453
+
454
+ # A generated discriminated union (< Serialization::Union, resolved by lexical scope).
455
+ # nested holds its synthetic variant records (see nest_synthetic). spec_href links to
456
+ # the union's definition in the live spec (nil when the schema has none). object_only
457
+ # mirrors the schema's `objectOnly` signal: when true, a non-Hash payload is rejected
458
+ # rather than passed through (every arm is an object, so it can match no variant).
459
+ # scalar_values mirrors the schema's `scalarValues` signal: the exact literals a bare-scalar
460
+ # arm admits (input.Origin's "viewport" / "pointer"), so outbound rejects any other scalar.
461
+ UnionClass = Struct.new(:ruby_name, :discriminator_wire, :variants, :schema_name, :nested, :spec_href,
462
+ :object_only, :scalar_values, :outbound, :inbound, :variant_arg_sigs,
463
+ keyword_init: true) do
464
+ def union? = true
465
+ def value_variants = variants.select { |v| v.mode == :value }
466
+ def presence_variants = variants.select { |v| v.mode == :presence }
467
+ def fallback_variant = variants.find { |v| v.mode == :fallback }
468
+ def nested_types = nested || []
469
+
470
+ # A class-method factory per discriminated variant, so a caller builds the right
471
+ # variant record without naming its class or repeating the discriminator:
472
+ # `ExtensionData.path(path: '/x')` returns `ExtensionPath.new(path: '/x')`. The method
473
+ # name is the variant's discriminator symbol; every value variant's ref is a record, so
474
+ # `.new` is always defined. Presence/fallback arms are omitted (no single tag to name).
475
+ def variant_factories
476
+ value_variants.map do |variant|
477
+ "def self.#{BiDiGenerate.enum_key(variant.value)}(**) = #{variant.ref}.new(**)"
478
+ end
479
+ end
480
+
481
+ # RBS for variant_factories: the variant record's own typed `new` signature (threaded in
482
+ # as variant_arg_sigs at build time), so a call is checked against the record's fields
483
+ # rather than an opaque splat; the return type pins the concrete variant.
484
+ def rbs_variant_factories
485
+ value_variants.map do |variant|
486
+ args = (variant_arg_sigs || {})[BiDiGenerate.enum_key(variant.value)] || '**untyped'
487
+ "def self.#{BiDiGenerate.enum_key(variant.value)}: (#{args}) " \
488
+ "-> ::Selenium::WebDriver::BiDi::Protocol::#{variant.ref}"
489
+ end
490
+ end
491
+
492
+ # The union's RBS *value* type — the concrete types a value of this union can actually be:
493
+ # each variant record, plus any bare-scalar arm (input.Origin's "viewport"/"pointer"). The
494
+ # union class itself has no instances, so this alias (not the class) is what a field, param,
495
+ # or result of the union is typed to, letting a variant pass where the union is expected.
496
+ def rbs_value_type
497
+ refs = (value_variants + presence_variants + [fallback_variant].compact).map(&:ref).uniq
498
+ parts = refs.map { |ref| "::Selenium::WebDriver::BiDi::Protocol::#{ref}" }
499
+ parts += Array(scalar_values).map { |value| value.is_a?(::String) ? value.inspect : value.to_s }
500
+ parts.empty? ? 'untyped' : parts.join(' | ')
501
+ end
502
+
503
+ # `discriminator 'wire'`, or `discriminator 'wire', {sym: 'token', …}` (wrapped when
504
+ # long) carrying the inbound wire->symbol map for string-tagged variants.
505
+ def discriminator_decl(indent)
506
+ pairs = value_variants.filter_map(&:discriminator_pair)
507
+ head = "discriminator '#{discriminator_wire}'"
508
+ return head if pairs.empty?
509
+
510
+ BiDiGenerate.wrap_call("#{head}, ", pairs, indent, open: '{', close: '}')
511
+ end
512
+
513
+ def scalar_values? = !(scalar_values.nil? || scalar_values.empty?)
514
+
515
+ # `scalar_values 'viewport', 'pointer'` — the literals a bare-scalar arm admits.
516
+ def scalar_values_decl
517
+ "scalar_values #{scalar_values.map { |v| BiDiGenerate.ruby_literal(v) }.join(', ')}"
518
+ end
519
+ end
520
+
521
+ # A prefix-free accessor emitted on the Domain subclass. method_name is the snake_case
522
+ # accessor; type_name is the local class it fronts. Three kinds route rendering: a union
523
+ # accessor returns the class so its variant factories dispatch; a record accessor
524
+ # constructs the instance directly; a vendor accessor returns a sibling vendor domain
525
+ # (`Moz.new(connection)`). rbs_args is the record's typed `new` signature (nil otherwise).
526
+ # See build_accessors / vendor_accessors.
527
+ Accessor = Struct.new(:method_name, :type_name, :union, :vendor, :rbs_args, keyword_init: true) do
528
+ def union? = union
529
+ def vendor? = vendor
530
+ end
531
+
532
+ # spec_href links the domain's module section in the live spec (nil when unknown).
533
+ Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, :accessors,
534
+ :vendor_modules, :spec_href, keyword_init: true)
535
+
536
+ class Schema
537
+ def initialize(schema)
538
+ @types = schema['types']
539
+ @commands = schema['commands']
540
+ @events = schema['events']
541
+ @domains = schema['domains'] || {}
542
+ @vendor = schema['vendor'] || {}
543
+ promote_command_params_records!
544
+ end
545
+
546
+ # The domain's `#module-<domain>` spec link, or nil when the schema has none.
547
+ def domain_href(domain)
548
+ @domains.dig(domain, 'specHref')
549
+ end
550
+
551
+ # A command written in CDDL map form carries its params as an *inline* object (rather
552
+ # than the usual group form referencing a named params type). The projector links the
553
+ # command to those params, but hoists them into a synthetic record owned by the
554
+ # command's message envelope. That envelope is suppressed (Transport forms it), so the
555
+ # synthetic params record would never be emitted even though the command's params ref
556
+ # points straight at it. Promote it to a top-level domain record so the generator emits
557
+ # and references it like any other params type. Today this is exactly
558
+ # `userAgentClientHints.setClientHintsOverride`.
559
+ def promote_command_params_records!
560
+ @commands.each do |cmd|
561
+ ref = cmd.dig('params', 'ref')
562
+ next unless ref
563
+
564
+ type = @types[ref]
565
+ promote_to_domain_type!(ref) if type && envelope_synthetic?(type)
566
+ end
567
+ end
568
+
569
+ # Strip the synthetic/owner/label tags so a lifted-out type emits as a top-level
570
+ # domain record instead of nesting under its (suppressed) envelope.
571
+ def promote_to_domain_type!(name)
572
+ type = @types[name]
573
+ type&.delete('synthetic')
574
+ type&.delete('owner')
575
+ type&.delete('label')
576
+ end
577
+
578
+ # Domains that carry a command or event each become one generated module.
579
+ def domains
580
+ (@commands + @events).map { |entry| entry['domain'] }.uniq
581
+ end
582
+
583
+ def commands_for(domain)
584
+ @commands.select { |c| c['domain'] == domain }
585
+ end
586
+
587
+ # The domain's command param/result wrapper type names — the classes a command
588
+ # constructs (`params`) or parses its result into. They are reachable (so tagged
589
+ # outbound/inbound) but are the message wrappers a command method already builds,
590
+ # not data a caller composes, so they are excluded from the type accessors.
591
+ def command_wrapper_refs(domain)
592
+ commands_for(domain).flat_map { |c| [c.dig('params', 'ref'), c.dig('result', 'ref')] }.compact.to_set
593
+ end
594
+
595
+ # Type names reached by at least one non-union-arm reference: used as a record field,
596
+ # list element, map value, or alias target somewhere — not solely as a named union's
597
+ # variant. A type reached only as a union arm is built through its union (a variant
598
+ # factory or the command's flattened dispatch), so a nested one needs no accessor; one
599
+ # reached as a plain field ref (browsingContext.AccessibilityLocator's `value`) does.
600
+ def plainly_reached_types
601
+ @plainly_reached_types ||= @types.each_value.with_object(Set.new) do |node, reached|
602
+ plain_refs(node).each { |ref| reached << ref }
603
+ end
604
+ end
605
+
606
+ # The class path to a type relative to its domain class (an accessor body resolves in
607
+ # the Domain subclass scope): "ExtensionData", or "AccessibilityLocator::Value" for a
608
+ # synthetic nested under its owner.
609
+ def domain_relative_path(name)
610
+ prefix = "#{BiDiGenerate.snake_to_class_name(BiDiGenerate.camel_to_snake(name.split('.', 2).first))}::"
611
+ ruby_path(name).sub(/\A#{Regexp.escape(prefix)}/, '')
612
+ end
613
+
614
+ # The vendor modules a domain carries, one per namespace (`moz` → module `Moz`). The
615
+ # schema's `vendor` section names, per namespace, which shared type each vendor extends;
616
+ # we map that type back to the command that sends it, so the vendor method mirrors the
617
+ # base command's wire method and result while adding the typed vendor fields. Empty for
618
+ # any domain (or schema) with no vendor extensions, so non-vendor output is unaffected.
619
+ def vendor_modules_for(domain)
620
+ parent = BiDiGenerate.snake_to_class_name(BiDiGenerate.camel_to_snake(domain))
621
+ groups = Hash.new { |h, k| h[k] = [] }
622
+ @vendor.each do |namespace, spec|
623
+ (spec['extends'] || {}).each do |type_name, entry|
624
+ cmd = @commands.find { |c| c.dig('params', 'ref') == type_name }
625
+ next unless cmd && cmd['domain'] == domain
626
+
627
+ groups[namespace] << build_vendor_command(cmd, type_name, entry, namespace)
628
+ end
629
+ end
630
+ groups.map do |namespace, commands|
631
+ VendorModule.new(name: BiDiGenerate.snake_to_class_name(namespace), namespace: namespace, parent: parent,
632
+ commands: commands)
633
+ end
634
+ end
635
+
636
+ def build_vendor_command(cmd, type_name, entry, namespace)
637
+ shared = record_params(@types[type_name]['fields'])
638
+ taken = shared.map(&:ruby_name)
639
+ VendorCommand.new(
640
+ method_name: BiDiGenerate.safe_method_name(BiDiGenerate.camel_to_snake(cmd['name'])),
641
+ wire_name: cmd['method'],
642
+ result_ref: cmd['result'] && structured_ref(cmd['result']['ref']),
643
+ params_class: BiDiGenerate.type_class_name(type_name),
644
+ shared_params: shared,
645
+ vendor_params: entry['fields'].map { |field| vendor_param(field, namespace, taken) },
646
+ spec_href: cmd['specHref']
647
+ )
648
+ end
649
+
650
+ # A vendor field's ruby name drops its namespace prefix (`moz:permanent` → permanent): the
651
+ # module already scopes it, so re-encoding the namespace in every identifier is redundant. The
652
+ # wire key is untouched. Falls back to the prefixed name only if stripping would collide with a
653
+ # shared param on the same command.
654
+ def vendor_param(field, namespace, taken)
655
+ stripped = field['name'].sub(/\A#{Regexp.escape(namespace)}:/, '')
656
+ ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(stripped))
657
+ ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])) if taken.include?(ruby_name)
658
+ Param.new(
659
+ ruby_name: ruby_name,
660
+ wire_name: field['wire'],
661
+ required: field['required'],
662
+ enum: enum_const(field['type']),
663
+ rbs: rbs_type(field['type'])
664
+ )
665
+ end
666
+
667
+ def type_kind(ref)
668
+ @types[ref]&.fetch('kind', nil)
669
+ end
670
+
671
+ def events_for(domain)
672
+ @events.select { |e| e['domain'] == domain }
673
+ end
674
+
675
+ # Flat params for a command: the record's fields, or — for a union of
676
+ # records — the merged superset of variant fields. Returns [] for commands
677
+ # with no params, or nil when params can't be flattened (alias, or a union
678
+ # whose variants aren't all records) so the caller forwards verbatim.
679
+ def params_for(params_ref)
680
+ return [] unless params_ref
681
+
682
+ type = @types[params_ref['ref']]
683
+ return nil unless type
684
+
685
+ case type['kind']
686
+ when 'record' then record_params(type['fields'])
687
+ when 'union' then union_params(type, params_ref['ref'])
688
+ end
689
+ end
690
+
691
+ # Enum types declared under "<domain>." become nested constant modules.
692
+ def enums_for(domain)
693
+ @types.filter_map do |name, type|
694
+ next unless type['kind'] == 'enum'
695
+ next unless name.start_with?("#{domain}.")
696
+
697
+ pairs = type['values'].map { |v| [BiDiGenerate.enum_key(v), v.to_s] }
698
+ Enum.new(constant_name: BiDiGenerate.screaming_snake(name.sub("#{domain}.", '')), pairs: pairs,
699
+ spec_href: type['specHref'])
700
+ end
701
+ end
702
+
703
+ # The protocol-root ErrorCode enum's wire values (e.g. "no such frame"), in schema order.
704
+ # Used to generate the BiDi-specific Error subclasses. [] when the schema has no ErrorCode.
705
+ def error_codes
706
+ @types.dig('ErrorCode', 'values') || []
707
+ end
708
+
709
+ # Structured value classes (records + discriminated unions) declared under
710
+ # "<domain>." Empty records are projector artifacts with nothing to carry, so
711
+ # they stay opaque hashes; only non-empty records and unions become classes.
712
+ # Command/event message envelopes (the `{method, params}` wire wrapper) are
713
+ # skipped — Transport forms that envelope, so nothing references them.
714
+ def types_for(domain)
715
+ prefix = "#{domain}."
716
+ @types.filter_map do |name, type|
717
+ next unless name.start_with?(prefix)
718
+
719
+ case type['kind']
720
+ when 'record' then record_class(name, type) unless type['fields'].empty? || suppressed_record?(type)
721
+ when 'union' then union_class(name)
722
+ when 'alias' then union_class(name) if type['type'].key?('union')
723
+ end
724
+ end
725
+ end
726
+
727
+ # Records the generator deliberately does not emit: a message envelope, or a
728
+ # synthetic params record lifted out of one. Both are reachable only through the
729
+ # envelope, which Transport replaces — so nothing else references them.
730
+ def suppressed_record?(type)
731
+ message_envelope?(type) || envelope_synthetic?(type)
732
+ end
733
+
734
+ # A protocol message envelope is a record with a baked `method` discriminator
735
+ # (`{method: <const>, params: …}`) — the wire shape of a command/event message.
736
+ # No value type carries a const `method` field, so this is unambiguous.
737
+ def message_envelope?(type)
738
+ type['fields'].any? { |f| f['wire'] == 'method' && f['type'].key?('const') }
739
+ end
740
+
741
+ # A synthetic record lifted out as an envelope's params (its owner is an envelope).
742
+ def envelope_synthetic?(type)
743
+ return false unless type['synthetic']
744
+
745
+ owner = @types[type['owner']]
746
+ owner && owner['kind'] == 'record' && message_envelope?(owner)
747
+ end
748
+
749
+ # The Protocol-relative class path a command result parses into, or nil when
750
+ # it is non-structured (or a bare list, returned raw).
751
+ def structured_ref(name)
752
+ resolved = resolve_named(name)
753
+ resolved[:list] ? nil : resolved[:ref]
754
+ end
755
+
756
+ # Public ruby-path resolver (`Owner::Label` for a synthetic), matching how a variant's
757
+ # ref is emitted — so a caller can map a variant ref back to its emitted record.
758
+ def ruby_path_for(name) = ruby_path(name)
759
+
760
+ private
761
+
762
+ def domain_path(name)
763
+ name.include?('.') ? ruby_path(name) : nil
764
+ end
765
+
766
+ # The refs a node exposes through a NON-arm position: a record's fields and map value,
767
+ # or an alias's target. A named union contributes none — its variants are arm positions
768
+ # (built through the union), so they do not count toward plainly_reached_types.
769
+ def plain_refs(node)
770
+ case node['kind']
771
+ when 'record'
772
+ refs = node['fields'].flat_map { |f| refs_in_type(f['type']) }
773
+ node['map'] ? refs + refs_in_type(node['map']) : refs
774
+ when 'alias' then refs_in_type(node['type'])
775
+ else []
776
+ end
777
+ end
778
+
779
+ # Every type name a *type expression* references (mirrors the projector's refsInType),
780
+ # descending list element, map value, inline union arms, and inline record fields. An
781
+ # inline union arm inside a field is a plain position — the field is filled with it.
782
+ def refs_in_type(node)
783
+ return [] unless node
784
+ return [node['ref']] if node['ref']
785
+ return refs_in_type(node['list']) if node['list']
786
+ return refs_in_type(node['map']) if node['map']
787
+ return node['union'].flat_map { |arm| refs_in_type(arm) } if node['union']
788
+ return node['record'].flat_map { |f| refs_in_type(f['type']) } if node['record']
789
+
790
+ []
791
+ end
792
+
793
+ # Class path, nesting a synthetic type under its owner as `Owner::Label` so a ref
794
+ # resolves to the same nested constant the type is emitted as.
795
+ def ruby_path(name)
796
+ type = @types[name]
797
+ return BiDiGenerate.type_ruby_path(name) unless type && type['synthetic']
798
+
799
+ "#{ruby_path(type['owner'])}::#{type['label']}"
800
+ end
801
+
802
+ # Resolution for anything not modeled as a value type (scalar, enum, empty record).
803
+ # Frozen because it is shared across callers.
804
+ OPAQUE = {ref: nil, list: false, rbs: 'untyped'}.freeze
805
+
806
+ # Projects a schema type node to {ref:, list:, nullable:, rbs:}. Deriving the
807
+ # serialization facts and the RBS signature from one walk keeps them from drifting
808
+ # apart when the schema shape changes.
809
+ def resolve(node)
810
+ nullable = node['nullable'] ? true : false
811
+ if node.key?('list')
812
+ element = resolve(node['list'])
813
+ return {ref: element[:ref], list: true, nullable: nullable, scalar: element[:scalar],
814
+ rbs: nilable("Array[#{element[:rbs]}]", nullable)}
815
+ end
816
+ if node.key?('ref')
817
+ named = resolve_named(node['ref'])
818
+ return {ref: named[:ref], list: named[:list], nullable: nullable, scalar: named[:scalar],
819
+ rbs: nilable(named[:rbs], nullable)}
820
+ end
821
+ return resolve_union(node, nullable) if node.key?('union')
822
+
823
+ {ref: nil, list: false, nullable: nullable, rbs: nilable(scalar_rbs(node), nullable)}
824
+ end
825
+
826
+ # An inline union of one union-typed arm plus scalars (e.g. a MappingRemoteValue entry,
827
+ # RemoteValue / string) is carried as that union ref so nested entries are typed. Because
828
+ # the union is object_only, a bare-scalar sibling would raise there — so the projector's
829
+ # `scalar` signal (a bare-scalar arm is present) is forwarded, and the runtime passes a
830
+ # non-object leaf through instead (the map's string keys). Any other shape (a record arm,
831
+ # multiple structured arms, all scalars) stays opaque.
832
+ def resolve_union(node, nullable)
833
+ refs = node['union'].select { |arm| arm.key?('ref') }
834
+ opaque = {ref: nil, list: false, nullable: nullable, rbs: nilable('untyped', nullable)}
835
+ return opaque unless refs.one? && union_ref?(refs.first['ref'])
836
+
837
+ named = resolve_named(refs.first['ref'])
838
+ {ref: named[:ref], list: named[:list], nullable: nullable, scalar: node['scalar'],
839
+ rbs: nilable('untyped', nullable)}
840
+ end
841
+
842
+ # True when a ref (following aliases) is a union — the only arm whose from_json tolerates a
843
+ # scalar sibling. A record arm would raise on one, so it is not carried.
844
+ def union_ref?(name)
845
+ type = @types[name]
846
+ return false unless type
847
+ return union_ref?(type['type']['ref']) if type['kind'] == 'alias' && type['type'].key?('ref')
848
+
849
+ type['kind'] == 'union'
850
+ end
851
+
852
+ # Resolves a named ref to the same {ref:, list:, rbs:} facts, transparently
853
+ # following aliases — including alias-to-list — so an element type behind an alias
854
+ # (e.g. script.ListLocalValue -> [script.LocalValue]) is preserved. Nullability is a
855
+ # property of the referencing node (applied by +resolve+), so it is not threaded
856
+ # here. seen guards against cyclic ref-aliases.
857
+ def resolve_named(name, seen = {})
858
+ return OPAQUE if name.nil? || seen[name]
859
+
860
+ seen[name] = true
861
+ type = @types[name]
862
+ return OPAQUE unless type
863
+
864
+ case type['kind']
865
+ when 'record' then type['fields'].empty? ? OPAQUE : named_type(name)
866
+ when 'union' then named_union(name)
867
+ when 'enum' then {ref: nil, list: false, rbs: 'Symbol'}
868
+ when 'alias' then resolve_named_alias(name, type['type'], seen)
869
+ else OPAQUE
870
+ end
871
+ end
872
+
873
+ # A named structured type's serialization ref (nil for a dotless/global type, never
874
+ # emitted as a class) and its absolute RBS class path, derived independently so each
875
+ # output keeps its own treatment of dotless names.
876
+ def named_type(name)
877
+ {ref: domain_path(name), list: false, rbs: rbs_abs(ruby_path(name))}
878
+ end
879
+
880
+ # Like named_type, but a union is typed to its value alias (variant | variant | …), not
881
+ # its class — the class has no instances, so a variant must be assignable where the union
882
+ # is expected. The serialization ref is unchanged (still the union that dispatches inbound).
883
+ def named_union(name)
884
+ {ref: domain_path(name), list: false, rbs: union_alias_path(name)}
885
+ end
886
+
887
+ # Absolute RBS path of a union's value alias: its class path with the last segment
888
+ # snake-cased (WebExtension::ExtensionData -> ...::WebExtension::extension_data), matching
889
+ # the `type` alias emitted alongside the class.
890
+ def union_alias_path(name)
891
+ segments = ruby_path(name).split('::')
892
+ segments[-1] = BiDiGenerate.camel_to_snake(segments[-1])
893
+ rbs_abs(segments.join('::'))
894
+ end
895
+
896
+ def resolve_named_alias(name, inner, seen)
897
+ return named_union(name) if inner.key?('union')
898
+ return resolve_named(inner['ref'], seen) if inner.key?('ref')
899
+
900
+ if inner.key?('list')
901
+ element = resolve(inner['list'])
902
+ return {ref: element[:ref], list: true, scalar: element[:scalar], rbs: "Array[#{element[:rbs]}]"}
903
+ end
904
+
905
+ {ref: nil, list: false, rbs: scalar_rbs(inner)}
906
+ end
907
+
908
+ def nilable(type, flag)
909
+ flag ? BiDiGenerate.rbs_nilable(type) : type
910
+ end
911
+
912
+ # The type's send/receive tags (schema `outbound`/`inbound`) as constructor kwargs,
913
+ # coerced to plain booleans — shared by every structured-type builder.
914
+ def directionality(name)
915
+ node = @types[name]
916
+ {outbound: node['outbound'] ? true : false, inbound: node['inbound'] ? true : false}
917
+ end
918
+
919
+ def record_class(name, type)
920
+ const = type['fields'].find { |f| baked_discriminator?(f) }
921
+ discriminator = const && {ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(const['name'])),
922
+ wire: const['wire'], value: const['type']['const'],
923
+ rbs: rbs_const(const['type']['const'])}
924
+ fields = type['fields'].reject { |f| baked_discriminator?(f) }.map { |f| field_ir(f) }
925
+ # Every extensible type gets the extensions store: an undeclared wire key is preserved
926
+ # and echoed back on any type the spec marks extensible, whether or not it is re-sendable.
927
+ # Extensibility alone is the signal; send-reachability does not enter into it.
928
+ TypeClass.new(ruby_name: BiDiGenerate.type_class_name(name), fields: fields,
929
+ discriminator: discriminator, extensible: type['extensible'] ? true : false,
930
+ schema_name: name, synthetic: type['synthetic'] ? true : false,
931
+ owner: type['owner'], label: type['label'], spec_href: type['specHref'],
932
+ **directionality(name))
933
+ end
934
+
935
+ # A const field is a baked discriminator tag, unless it is also nullable: the spec's
936
+ # `literal | null` (browsingContext.setBypassCSP, emulation.setScriptingEnabled) is a
937
+ # settable value (the literal to set, null to clear), so it stays a normal field that
938
+ # can serialize null rather than a fixed tag that can only ever emit the literal.
939
+ def baked_discriminator?(field)
940
+ field['type'].key?('const') && !field['type']['nullable']
941
+ end
942
+
943
+ def field_ir(field)
944
+ resolved = resolve(field['type'])
945
+ ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name']))
946
+ FieldIR.new(ruby_name: ruby_name, wire_key: field['wire'],
947
+ required: field['required'], nullable: resolved[:nullable],
948
+ ref: resolved[:ref], list: resolved[:list], enum: enum_const(field['type']),
949
+ primitive: leaf_primitive(field['type']), scalar: resolved[:scalar],
950
+ const: leaf_const(field['type']), rbs: resolved[:rbs])
951
+ end
952
+
953
+ # The literal value of a const field, following alias chains, so the runtime can reject a
954
+ # value that is neither the literal nor null (a `literal / null` param such as
955
+ # emulation.setScriptingEnabled's `enabled`). Nil for any non-const node — const literals are
956
+ # never nil, so nil unambiguously means "no const" (a null value is carried by `nullable`).
957
+ def leaf_const(node, seen = {})
958
+ return node['const'] if node.key?('const')
959
+ return nil unless node.key?('ref')
960
+
961
+ name = node['ref']
962
+ type = @types[name]
963
+ return nil if seen[name] || type.nil? || type['kind'] != 'alias'
964
+
965
+ seen[name] = true
966
+ leaf_const(type['type'], seen)
967
+ end
968
+
969
+ # The runtime-checkable scalar primitive of a field, following alias chains so a
970
+ # scalar hidden behind a named alias (js-uint -> integer, browsingContext.BrowsingContext
971
+ # -> string) is typed rather than opaque. The projector carries the primitive on the
972
+ # alias node; this surfaces it onto the field. Nil for a list (its elements are not
973
+ # scalar-checked), a record/union ref, an enum, a const, or an opaque value.
974
+ def leaf_primitive(node, seen = {})
975
+ return node['primitive'] if node.key?('primitive') && CHECKABLE_PRIMITIVES.include?(node['primitive'])
976
+ return nil unless node.key?('ref')
977
+
978
+ name = node['ref']
979
+ type = @types[name]
980
+ return nil if seen[name] || type.nil? || type['kind'] != 'alias'
981
+
982
+ seen[name] = true
983
+ leaf_primitive(type['type'], seen)
984
+ end
985
+
986
+ def union_class(name)
987
+ type = @types[name]
988
+ # A first-class union carries the schema's authoritative dispatch `selector`
989
+ # (derived spec-faithfully, including null discriminators and the spec's choice
990
+ # order); consume it rather than re-deriving and silently depending on emit
991
+ # order. An alias-to-union (only input.Origin) has no selector — its const-string
992
+ # arms aren't first-class types — so it keeps the structural re-derivation.
993
+ klass = type['kind'] == 'union' ? union_from_selector(name, type['selector']) : union_from_alias(name)
994
+ # A non-object_only union has a bare-scalar arm; only const-literal arms (scalar_values) are
995
+ # modeled, so the runtime can validate an outbound scalar. A non-object_only union without them
996
+ # is a shape the generator doesn't yet handle — fail here, at generation, not at a caller's runtime.
997
+ if !klass.object_only && !klass.scalar_values?
998
+ raise "non-object_only union #{name} has no scalar_values to validate its bare-scalar arm"
999
+ end
1000
+
1001
+ klass
1002
+ end
1003
+
1004
+ # Map a union `selector` to dispatch variants the template renders:
1005
+ # { by, variants, default? } -> a discriminator table (value => ref), `default`
1006
+ # as the fallback (it may itself be a union, which finishes the dispatch).
1007
+ # { ordered: [{ ref, requires }] } -> presence rules in the spec's choice order.
1008
+ # { correlated: true } -> resolved by request id, not the payload, so no payload
1009
+ # dispatch. Unreachable here: every correlated union is a top-level result
1010
+ # grouping, never domain-scoped, so it is never emitted as a class.
1011
+ def union_from_selector(name, selector)
1012
+ # A correlated union is resolved by request id, never the payload, so it carries
1013
+ # no dispatch — it must never be emitted (every one is a top-level result
1014
+ # grouping). Fail loudly if a future schema makes one domain-scoped rather than
1015
+ # emit a Union whose every parse would raise.
1016
+ if selector['correlated']
1017
+ raise "correlated union #{name} must not be emitted (resolved by request id, not payload)"
1018
+ end
1019
+
1020
+ variants = selector['by'] ? discriminated_variants(selector) : ordered_variants(selector)
1021
+ raise "union #{name} selector yielded no dispatch variants" if variants.empty?
1022
+
1023
+ UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
1024
+ discriminator_wire: selector['by'], variants: variants, schema_name: name,
1025
+ spec_href: @types[name]['specHref'], object_only: @types[name]['objectOnly'] ? true : false,
1026
+ **directionality(name))
1027
+ end
1028
+
1029
+ def discriminated_variants(selector)
1030
+ variants = selector['variants'].map do |variant|
1031
+ VariantIR.new(mode: :value, value: variant['value'], ref: ruby_path(variant['ref']), requires: nil)
1032
+ end
1033
+ return variants unless selector['default']
1034
+
1035
+ variants << VariantIR.new(mode: :fallback, value: nil, ref: ruby_path(selector['default']), requires: nil)
1036
+ end
1037
+
1038
+ def ordered_variants(selector)
1039
+ (selector['ordered'] || []).map do |arm|
1040
+ VariantIR.new(mode: :presence, value: nil, ref: ruby_path(arm['ref']), requires: arm['requires'])
1041
+ end
1042
+ end
1043
+
1044
+ # The sole alias-union is input.Origin ("viewport" | "pointer" | ElementOrigin): a
1045
+ # scalar-or-object union the object-payload selector model doesn't cover, so the
1046
+ # projector leaves it an alias with no selector. Its object arm(s) carry a const
1047
+ # discriminator; the bare-string arms need no dispatch (Union.from_json returns a
1048
+ # non-Hash payload unchanged). So dispatch the ref arms by their const tag.
1049
+ def union_from_alias(name)
1050
+ spec = @types[name]
1051
+ consts = spec['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref|
1052
+ const = @types[ref]['fields'].find { |f| f['type'].key?('const') }
1053
+ const || raise("alias-union #{name} arm #{ref} has no const discriminator to dispatch on")
1054
+ [ref, const]
1055
+ end
1056
+ variants = consts.map do |ref, const|
1057
+ VariantIR.new(mode: :value, value: const['type']['const'], ref: ruby_path(ref), requires: nil)
1058
+ end
1059
+ # An alias-union carries bare-scalar arms (input.Origin's "viewport"/"pointer"), so it
1060
+ # is never object_only — those arms must still pass a non-Hash payload through, but only
1061
+ # a value the schema pins in scalarValues (so a stray "banana" is still rejected outbound).
1062
+ UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
1063
+ discriminator_wire: consts.values.first['wire'], variants: variants, schema_name: name,
1064
+ spec_href: spec['specHref'], object_only: spec['objectOnly'] ? true : false,
1065
+ scalar_values: spec['type']['scalarValues'], **directionality(name))
1066
+ end
1067
+
1068
+ def record_params(fields)
1069
+ fields.map do |field|
1070
+ Param.new(
1071
+ ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
1072
+ wire_name: field['wire'],
1073
+ required: field['required'],
1074
+ enum: enum_const(field['type']),
1075
+ rbs: rbs_type(field['type'])
1076
+ )
1077
+ end
1078
+ end
1079
+
1080
+ def rbs_type(node)
1081
+ resolve(node)[:rbs]
1082
+ end
1083
+
1084
+ # Every primitive the projector can emit maps to an RBS type. `unknown` is intentionally
1085
+ # absent — the projector rejects it (an unhandled CDDL construct fails the build), so it
1086
+ # never reaches here; any other unlisted primitive fails generation at scalar_rbs rather
1087
+ # than slipping through as untyped.
1088
+ PRIMITIVE_RBS = {
1089
+ 'string' => 'String', 'number' => 'Numeric', 'integer' => 'Integer', 'boolean' => 'bool', 'null' => 'nil'
1090
+ }.freeze
1091
+
1092
+ # The scalar primitives that carry an inbound type-check. A field with no primitive (a ref,
1093
+ # const, or opaque value) gets no descriptor and is left unchecked — lenient, so a missed
1094
+ # check fails open rather than a wrong strict default rejecting valid data.
1095
+ CHECKABLE_PRIMITIVES = %w[string number integer boolean].freeze
1096
+
1097
+ # The leaf of +resolve+: the bare scalar type, before any nullable wrap. An alias's
1098
+ # own nullable is intentionally left off — only the referencing node's is applied.
1099
+ def scalar_rbs(node)
1100
+ return PRIMITIVE_RBS.fetch(node['primitive']) if node.key?('primitive')
1101
+ return rbs_const(node['const']) if node.key?('const')
1102
+
1103
+ 'untyped'
1104
+ end
1105
+
1106
+ def rbs_const(value)
1107
+ case value
1108
+ when true, false then 'bool'
1109
+ when ::String then 'String'
1110
+ when ::Numeric then 'Numeric'
1111
+ else 'untyped'
1112
+ end
1113
+ end
1114
+
1115
+ def rbs_abs(path)
1116
+ "::Selenium::WebDriver::BiDi::Protocol::#{path}"
1117
+ end
1118
+
1119
+ # The allowed-values constant path when a field (or a list's element) is an enum
1120
+ # type, else nil. Union command-params skip this (their merged superset can blur a
1121
+ # discriminator's const vs enum); only flat record params get the outbound check.
1122
+ def enum_const(field_type)
1123
+ ref = field_type['ref'] || field_type.dig('list', 'ref')
1124
+ return unless ref && @types[ref] && @types[ref]['kind'] == 'enum'
1125
+
1126
+ BiDiGenerate.enum_const_path(ref)
1127
+ end
1128
+
1129
+ # Merge a union's record variants into one flat param list for the command
1130
+ # signature. A field is only required when every variant declares it required;
1131
+ # variant-specific fields become optional. The command body dispatches these
1132
+ # kwargs to the matching variant via `Union.build`, whose typed `as_json` handles
1133
+ # null-vs-absent — so no nullable allowlist is needed.
1134
+ def union_params(type, ref = nil)
1135
+ variants = type['variants'].map { |variant_ref| @types[variant_ref] }
1136
+ return nil unless variants.all? { |v| v && v['kind'] == 'record' }
1137
+
1138
+ selector = type['selector']
1139
+ guard_union_dispatch_keys_simple!(selector, ref)
1140
+ params = merged_params(variants.map { |v| v['fields'] })
1141
+ annotate_discriminator_enum!(params, selector)
1142
+ params
1143
+ end
1144
+
1145
+ # A discriminated union's `by` field is validated against the whole allowed set:
1146
+ # the const values that tag each variant plus the default variant's own enum
1147
+ # values (e.g. continueWithAuth.action = {provideCredentials} + {default, cancel}).
1148
+ # That spans variants, so no single enum constant fits — emit an inline symbol=>wire
1149
+ # hash so the check accepts the idiomatic symbol like every other enum.
1150
+ # Boolean discriminators (handleRequestDevicePrompt.accept) need no membership check.
1151
+ def annotate_discriminator_enum!(params, selector)
1152
+ by = selector['by']
1153
+ tagged = by ? selector['variants'].map { |v| v['value'] } : []
1154
+ return unless !tagged.empty? && tagged.all?(String)
1155
+
1156
+ allowed = (tagged + default_variant_enum_values(selector, by)).uniq
1157
+ pairs = allowed.map { |v| "#{BiDiGenerate.enum_key(v)}: '#{v}'" }
1158
+ param = params.find { |p| p.wire_name == by }
1159
+ return unless param
1160
+
1161
+ param.enum = "{#{pairs.join(', ')}}"
1162
+ param.rbs = 'Symbol'
1163
+ end
1164
+
1165
+ def default_variant_enum_values(selector, by)
1166
+ default = selector['default']
1167
+ field = default && @types[default]['fields'].find { |f| f['wire'] == by }
1168
+ ref = field && field['type']['ref']
1169
+ ref && @types[ref] && @types[ref]['kind'] == 'enum' ? @types[ref]['values'] : []
1170
+ end
1171
+
1172
+ # Merge variant field lists into one flat param superset. A field is required only
1173
+ # when every variant declares it required; variant-specific fields become optional.
1174
+ def merged_params(variant_fields)
1175
+ all_fields = variant_fields.flatten
1176
+ all_fields.map { |f| f['wire'] }.uniq.map do |wire|
1177
+ field = all_fields.find { |f| f['wire'] == wire }
1178
+ required = variant_fields.all? { |fields| fields.any? { |f| f['wire'] == wire && f['required'] } }
1179
+ Param.new(ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
1180
+ wire_name: wire, required: required, rbs: rbs_type(field['type']))
1181
+ end
1182
+ end
1183
+
1184
+ # `Union.build` matches the command's kwargs to the selector's dispatch keys by
1185
+ # symbol, which holds only while each dispatch wire key equals its ruby kwarg.
1186
+ # Every current key is a single lowercase word; fail generation if a new one is
1187
+ # camelCase so the outbound dispatch gets an explicit wire<->ruby mapping then.
1188
+ def guard_union_dispatch_keys_simple!(selector, ref)
1189
+ keys = selector['by'] ? [selector['by']] : (selector['ordered'] || []).flat_map { |arm| arm['requires'] }
1190
+ camel = keys.reject { |k| BiDiGenerate.camel_to_snake(k) == k }
1191
+ return if camel.empty?
1192
+
1193
+ raise "union command param #{ref} dispatches on non-snake wire key(s) #{camel.inspect}; " \
1194
+ 'Union.build matches kwargs to dispatch keys by symbol, so give the outbound ' \
1195
+ 'dispatch an explicit wire<->ruby mapping before shipping this.'
1196
+ end
1197
+ end
1198
+
1199
+ # Param kinds the named args can construct a Parameters object for (record fields,
1200
+ # or a union dispatched to one of its variants); anything else forwards a raw hash.
1201
+ PARAMS_CLASS_KINDS = %w[record union].freeze
1202
+
1203
+ def self.build_ir(schema)
1204
+ schema.domains.map do |domain|
1205
+ types = schema.types_for(domain)
1206
+ thread_variant_arg_sigs(schema, types)
1207
+ vendor_modules = schema.vendor_modules_for(domain)
1208
+ mod = Module.new(
1209
+ name: domain,
1210
+ ruby_class: snake_to_class_name(camel_to_snake(domain)),
1211
+ filename: camel_to_snake(domain),
1212
+ commands: schema.commands_for(domain).map { |cmd| build_command(schema, cmd) },
1213
+ events: schema.events_for(domain).map { |ev| build_event(schema, ev) },
1214
+ enums: schema.enums_for(domain),
1215
+ accessors: build_accessors(schema, domain, types) + vendor_accessors(vendor_modules),
1216
+ types: nest_synthetic(types),
1217
+ vendor_modules: vendor_modules,
1218
+ spec_href: schema.domain_href(domain)
1219
+ )
1220
+ check_accessor_collisions!(mod)
1221
+ mod
1222
+ end
1223
+ end
1224
+
1225
+ # An accessor per vendor variant, returning a sibling vendor domain over the same connection
1226
+ # (`web_extension.moz` -> `Moz.new(connection)`). Named after the vendor namespace.
1227
+ def self.vendor_accessors(vendor_modules)
1228
+ vendor_modules.map do |vendor_module|
1229
+ Accessor.new(method_name: safe_method_name(vendor_module.namespace), type_name: vendor_module.name,
1230
+ union: false, vendor: true)
1231
+ end
1232
+ end
1233
+
1234
+ # Give each union its variants' typed `new` signatures, keyed by factory method name, so
1235
+ # rbs_variant_factories can emit a checked signature instead of a splat. Keyed by ruby
1236
+ # path (the form a variant ref carries); a cross-module variant not in this list falls
1237
+ # back to `**untyped`.
1238
+ def self.thread_variant_arg_sigs(schema, types)
1239
+ record_sigs = types.reject(&:union?).to_h { |t| [schema.ruby_path_for(t.schema_name), t.rbs_new_args] }
1240
+ types.select(&:union?).each do |union|
1241
+ union.variant_arg_sigs = union.value_variants.to_h do |variant|
1242
+ [BiDiGenerate.enum_key(variant.value), record_sigs[variant.ref]]
1243
+ end
1244
+ end
1245
+ end
1246
+
1247
+ # Outbound-scoped domain accessors: for every emitted type a caller constructs to send,
1248
+ # a prefix-free constructor on the Domain subclass. Built from the pre-nesting type list
1249
+ # so a nested synthetic (referenced by a Ruby-relative `Owner::Label` path) is reachable.
1250
+ def self.build_accessors(schema, domain, types)
1251
+ wrappers = schema.command_wrapper_refs(domain)
1252
+ plainly_reached = schema.plainly_reached_types
1253
+ types.select { |t| accessor?(t, wrappers, plainly_reached) }.map do |t|
1254
+ Accessor.new(method_name: safe_method_name(camel_to_snake(type_class_name(t.schema_name))),
1255
+ type_name: schema.domain_relative_path(t.schema_name), union: t.union?,
1256
+ rbs_args: t.union? ? nil : t.rbs_new_args)
1257
+ end
1258
+ end
1259
+
1260
+ # A type earns a send-side accessor when it is outbound and not a command param/result
1261
+ # wrapper (a command method already builds those). A nested-away synthetic reached only
1262
+ # as a union arm is excluded — it is built through its union (a variant factory or the
1263
+ # command's flattened dispatch), never standalone. A top-level union variant record keeps
1264
+ # its accessor (the plan constructs it directly, e.g. extension_path), as does a synthetic
1265
+ # reached by a plain field ref (browsingContext.AccessibilityLocator's `value`).
1266
+ def self.accessor?(type, wrappers, plainly_reached)
1267
+ return false unless type.outbound
1268
+ return false if wrappers.include?(type.schema_name)
1269
+
1270
+ nested_synthetic = !type.union? && type.synthetic
1271
+ !nested_synthetic || plainly_reached.include?(type.schema_name)
1272
+ end
1273
+
1274
+ # Public instance methods every accessor would shadow if it reused their name:
1275
+ # Domain's own (`execute`/`initialize`) plus everything Object/Kernel expose. The
1276
+ # collision guard fails generation before a schema-driven shadow can ship.
1277
+ INHERITED_INSTANCE_METHODS = (%w[execute initialize].to_set + Object.instance_methods.to_set(&:to_s)).freeze
1278
+
1279
+ # Fail generation if an accessor name would collide with a command method, an
1280
+ # inherited method, or another accessor — turning a future shadow into a build error
1281
+ # rather than a silently overridden method.
1282
+ def self.check_accessor_collisions!(mod)
1283
+ commands = mod.commands.to_set(&:method_name)
1284
+ seen = {}
1285
+ mod.accessors.each do |accessor|
1286
+ name = accessor.method_name
1287
+ clash = if commands.include?(name) then 'a command method'
1288
+ elsif INHERITED_INSTANCE_METHODS.include?(name) then 'an inherited method'
1289
+ elsif seen[name] then "the accessor for #{seen[name]}"
1290
+ end
1291
+ raise "accessor #{mod.ruby_class}##{name} collides with #{clash}" if clash
1292
+
1293
+ seen[name] = accessor.type_name
1294
+ end
1295
+ end
1296
+
1297
+ def self.build_command(schema, cmd)
1298
+ params = schema.params_for(cmd['params'])
1299
+ # A param that can't flatten to a typed object (alias or non-record union) would be
1300
+ # silently dropped, so fail generation and handle that shape deliberately if it appears.
1301
+ if cmd['params'] && params.nil?
1302
+ raise "command #{cmd['method']} has params that cannot be expressed as a typed object"
1303
+ end
1304
+
1305
+ params_ref = cmd['params'] && cmd['params']['ref']
1306
+ params_kind = schema.type_kind(params_ref)
1307
+ params_class = type_class_name(params_ref) if !params.empty? && PARAMS_CLASS_KINDS.include?(params_kind)
1308
+ Command.new(
1309
+ wire_name: cmd['method'],
1310
+ method_name: safe_method_name(camel_to_snake(cmd['name'])),
1311
+ params: params,
1312
+ result_ref: cmd['result'] && schema.structured_ref(cmd['result']['ref']),
1313
+ params_class: params_class,
1314
+ union_params: params_kind == 'union',
1315
+ spec_href: cmd['specHref']
1316
+ )
1317
+ end
1318
+
1319
+ def self.build_event(schema, event)
1320
+ params = event['params']
1321
+ payload_ref = params && params['ref'] && schema.structured_ref(params['ref'])
1322
+ Event.new(wire_name: event['method'], event_name: camel_to_snake(event['name']), payload_ref: payload_ref)
1323
+ end
1324
+
1325
+ # The projector tags lifted-out types with {synthetic, owner, label}. Emit each
1326
+ # synthetic record inside its owner's class body under its bare label, so
1327
+ # `Owner_Label` becomes the nested `Owner::Label` (refs resolve there via
1328
+ # ruby_path). Synthetic enums stay domain-level. Raises on a missing owner.
1329
+ def self.nest_synthetic(types)
1330
+ index = types.to_h { |t| [t.schema_name, t] }
1331
+ children = types.select { |t| !t.union? && t.synthetic }
1332
+ children.each do |child|
1333
+ owner = index[child.owner] ||
1334
+ raise("synthetic type #{child.schema_name} has no emitted owner #{child.owner}")
1335
+ owner.nested = (owner.nested || []) << child
1336
+ child.ruby_name = child.label
1337
+ end
1338
+ types - children
1339
+ end
1340
+
1341
+ def self.render(mod, template_path)
1342
+ generated_note = GeneratedNote.render('#', 'rb/lib/selenium/webdriver/bidi/support/bidi_generate.rb',
1343
+ 'bazel run //rb/lib/selenium/webdriver:bidi-generate')
1344
+ ERB.new(File.read(template_path), trim_mode: '-').result(binding)
1345
+ end
1346
+
1347
+ def self.call(schema_path, output_dir)
1348
+ raw = load_json(schema_path)
1349
+ schema = Schema.new(raw)
1350
+ modules = build_ir(schema)
1351
+
1352
+ emit(modules, output_dir, 'module.rb.erb', 'rb')
1353
+ emit(modules, sig_dir(output_dir), 'module.rbs.erb', 'rbs')
1354
+ emit_error_module(schema, output_dir)
1355
+ end
1356
+
1357
+ # The ErrorCode wire values mapped to their Ruby exception class names (schema order), e.g.
1358
+ # "no such node" => "NoSuchNodeError". This is the schema->Ruby translation: the generated file
1359
+ # carries the Ruby names, and a hand-written pass turns them into WebDriverError subclasses under
1360
+ # the shared Error namespace. Self-contained — no reference to the classic error module.
1361
+ def self.error_code_map(schema)
1362
+ schema.error_codes.map { |code| [code, error_class_name(code)] }
1363
+ end
1364
+
1365
+ # WebDriver error-code string -> exception class name, matching Error.for_error's convention
1366
+ # ("no such node" -> NoSuchNodeError). The Error suffix is normalized (not doubled) for a code
1367
+ # already ending in "error" ("unknown error" -> UnknownError).
1368
+ def self.error_class_name(code)
1369
+ "#{code.split.map(&:capitalize).join.sub(/Error$/, '')}Error"
1370
+ end
1371
+
1372
+ # Writes protocol/error_code.rb (+ its .rbs), the Protocol::ErrorCode map, into the same protocol
1373
+ # dir as the generated domain files.
1374
+ def self.emit_error_module(schema, output_dir)
1375
+ codes = error_code_map(schema)
1376
+ mod = ErrorModule.new(filename: 'error_code', codes: codes, new_classes: bidi_only_classes(codes))
1377
+ emit([mod], output_dir, 'error_code.rb.erb', 'rb')
1378
+ emit([mod], sig_dir(output_dir), 'error_code.rbs.erb', 'rbs')
1379
+ end
1380
+
1381
+ # Class names among `codes` the classic Error module does not already define — the BiDi-only codes
1382
+ # bidi/error.rb registers and whose RBS this file must declare. Shared codes already have RBS in
1383
+ # common/error.rbs, so re-declaring them would duplicate the classic signatures. Only the RBS needs
1384
+ # this split; the emitted map (error_code.rb) stays the full self-contained set.
1385
+ def self.bidi_only_classes(codes)
1386
+ require_relative '../../common/error'
1387
+ codes.filter_map { |_wire, name| name unless ::Selenium::WebDriver::Error.const_defined?(name, false) }
1388
+ end
1389
+
1390
+ # Renders every module through one template and writes the result into target,
1391
+ # one file per module. Used for both the Ruby source and its RBS signatures.
1392
+ def self.emit(modules, output_dir, template, extension)
1393
+ target = File.join(workspace_root, output_dir)
1394
+ FileUtils.mkdir_p(target)
1395
+
1396
+ tmpl = File.join(File.dirname(__FILE__), 'templates', template)
1397
+ modules.each do |mod|
1398
+ path = File.join(target, "#{mod.filename}.#{extension}")
1399
+ File.write(path, render(mod, tmpl))
1400
+ warn "bidi-generate: wrote #{path}"
1401
+ end
1402
+ end
1403
+
1404
+ # The RBS signatures mirror the source tree under sig/ (the repo's convention),
1405
+ # e.g. rb/lib/.../protocol -> rb/sig/lib/.../protocol.
1406
+ def self.sig_dir(output_dir)
1407
+ output_dir.sub(%r{(\A|/)lib/}, '\1sig/lib/')
1408
+ end
1409
+
1410
+ private_class_method def self.load_json(path)
1411
+ resolved = File.exist?(path) ? path : File.join(Dir.pwd, path)
1412
+ JSON.parse(File.read(resolved))
1413
+ end
1414
+
1415
+ private_class_method def self.workspace_root
1416
+ ENV['BUILD_WORKSPACE_DIRECTORY'] || Dir.pwd
1417
+ end
1418
+ end
1419
+
1420
+ BiDiGenerate.call(*ARGV) if $PROGRAM_NAME == __FILE__