selenium-webdriver 4.46.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES +29 -0
  3. data/bin/linux/selenium-manager +0 -0
  4. data/bin/macos/selenium-manager +0 -0
  5. data/bin/selenium-manager-THIRD-PARTY-NOTICES.txt +5971 -0
  6. data/bin/selenium-manager.cdx.json +11629 -0
  7. data/bin/windows/selenium-manager.exe +0 -0
  8. data/lib/selenium/webdriver/{common/driver_extensions/has_bidi.rb → bidi/error.rb} +21 -13
  9. data/lib/selenium/webdriver/bidi/protocol/bluetooth.rb +53 -23
  10. data/lib/selenium/webdriver/bidi/protocol/browser.rb +43 -15
  11. data/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +172 -68
  12. data/lib/selenium/webdriver/bidi/protocol/domain.rb +8 -2
  13. data/lib/selenium/webdriver/bidi/protocol/emulation.rb +123 -9
  14. data/lib/selenium/webdriver/bidi/protocol/error_code.rb +66 -0
  15. data/lib/selenium/webdriver/bidi/protocol/input.rb +101 -28
  16. data/lib/selenium/webdriver/bidi/protocol/log.rb +17 -6
  17. data/lib/selenium/webdriver/bidi/protocol/network.rb +146 -63
  18. data/lib/selenium/webdriver/bidi/protocol/permissions.rb +4 -2
  19. data/lib/selenium/webdriver/bidi/protocol/script.rb +264 -86
  20. data/lib/selenium/webdriver/bidi/protocol/session.rb +48 -3
  21. data/lib/selenium/webdriver/bidi/protocol/speculation.rb +4 -2
  22. data/lib/selenium/webdriver/bidi/protocol/storage.rb +32 -6
  23. data/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb +7 -2
  24. data/lib/selenium/webdriver/bidi/protocol/web_extension.rb +49 -10
  25. data/lib/selenium/webdriver/bidi/protocol.rb +3 -0
  26. data/lib/selenium/webdriver/bidi/serialization/record.rb +194 -18
  27. data/lib/selenium/webdriver/bidi/serialization/union.rb +45 -3
  28. data/lib/selenium/webdriver/bidi/serialization.rb +13 -1
  29. data/lib/selenium/webdriver/bidi/support/bidi_generate.rb +529 -45
  30. data/lib/selenium/webdriver/bidi/support/check_generated.rb +12 -4
  31. data/lib/selenium/webdriver/bidi/transport.rb +8 -3
  32. data/lib/selenium/webdriver/chrome/service.rb +4 -1
  33. data/lib/selenium/webdriver/chromium/driver.rb +0 -1
  34. data/lib/selenium/webdriver/chromium/options.rb +20 -0
  35. data/lib/selenium/webdriver/common/driver.rb +11 -0
  36. data/lib/selenium/webdriver/common/driver_extensions/has_log_events.rb +0 -7
  37. data/lib/selenium/webdriver/common/driver_extensions/has_network_interception.rb +0 -7
  38. data/lib/selenium/webdriver/common/driver_finder.rb +4 -3
  39. data/lib/selenium/webdriver/common/options.rb +21 -1
  40. data/lib/selenium/webdriver/common/proxy.rb +0 -8
  41. data/lib/selenium/webdriver/common/selenium_manager.rb +2 -1
  42. data/lib/selenium/webdriver/common/takes_screenshot.rb +1 -1
  43. data/lib/selenium/webdriver/common.rb +0 -1
  44. data/lib/selenium/webdriver/edge/service.rb +4 -1
  45. data/lib/selenium/webdriver/firefox/driver.rb +0 -3
  46. data/lib/selenium/webdriver/firefox/options.rb +19 -0
  47. data/lib/selenium/webdriver/firefox/profile.rb +13 -6
  48. data/lib/selenium/webdriver/remote/bidi_bridge.rb +39 -12
  49. data/lib/selenium/webdriver/remote/bridge.rb +1 -1
  50. data/lib/selenium/webdriver/remote/driver.rb +3 -1
  51. data/lib/selenium/webdriver/remote/http/default.rb +1 -1
  52. data/lib/selenium/webdriver/safari/options.rb +16 -1
  53. data/lib/selenium/webdriver/support/guards/guard.rb +20 -3
  54. data/lib/selenium/webdriver/support/guards.rb +9 -3
  55. data/lib/selenium/webdriver/version.rb +1 -1
  56. metadata +6 -3
@@ -20,6 +20,7 @@
20
20
  require 'json'
21
21
  require 'erb'
22
22
  require 'fileutils'
23
+ require_relative '../../../../../support/generated_note'
23
24
 
24
25
  # Generates Ruby WebDriver BiDi protocol modules from the shared, binding-neutral
25
26
  # BiDi schema produced by the JavaScript generator (see PR #17700):
@@ -31,8 +32,8 @@ require 'fileutils'
31
32
  #
32
33
  # Invoked via `bazel run //rb/lib/selenium/webdriver:bidi-generate`. Bazel passes
33
34
  # 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
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.
36
37
  #
37
38
  # @api private
38
39
  module BiDiGenerate
@@ -104,6 +105,9 @@ module BiDiGenerate
104
105
  # Append underscore to a field name that would shadow a core method; the wire
105
106
  # name is unaffected, only the Ruby reader is renamed.
106
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(':', '_')
107
111
  RESERVED_FIELD_NAMES.include?(name) ? "#{name}_" : name
108
112
  end
109
113
 
@@ -172,7 +176,7 @@ module BiDiGenerate
172
176
  # command); union_params picks its variant via `.build` rather than `.new`. result_ref
173
177
  # is the Protocol-relative result class path, or nil to return the raw hash.
174
178
  Command = Struct.new(:wire_name, :method_name, :params, :result_ref, :params_class,
175
- :union_params, keyword_init: true) do
179
+ :union_params, :spec_href, keyword_init: true) do
176
180
  def required_params = params.select(&:required)
177
181
  def optional_params = params.reject(&:required)
178
182
  def enum_checks(indent) = params.filter_map { |p| p.enum_check(indent) }
@@ -222,6 +226,81 @@ module BiDiGenerate
222
226
  end
223
227
  end
224
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
+
225
304
  # payload_ref is the Protocol-relative class the event's params parse into (nil when
226
305
  # non-structured, dispatched raw) — the inbound counterpart to a command's result_ref.
227
306
  Event = Struct.new(:wire_name, :event_name, :payload_ref, keyword_init: true) do
@@ -230,30 +309,53 @@ module BiDiGenerate
230
309
  end
231
310
 
232
311
  # 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)
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)
234
320
 
235
321
  # ref is the Protocol-relative class path for a nested structured field (nil
236
322
  # for a scalar/opaque field); list wraps it in an array. wire_key is the exact
237
323
  # 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
324
+ FieldIR = Struct.new(:ruby_name, :wire_key, :required, :nullable, :ref, :list, :enum, :primitive, :scalar, :const,
325
+ :rbs, keyword_init: true) do
240
326
  # A `Serialization::Record.define` spec entry: `name: 'jsonKey'` shorthand, or
241
327
  # `name: {wire_key:, …}` when the field carries JSON facts beyond its name.
242
328
  # enum carries the allowed-values constant path, validated at construction.
243
329
  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
330
+ meta = value_facts
251
331
  return "#{ruby_name}: '#{wire_key}'" if meta.empty?
252
332
 
253
333
  meta.unshift("wire_key: '#{wire_key}'")
254
334
  BiDiGenerate.wrap_call("#{ruby_name}: ", meta, indent, open: '{', close: '}')
255
335
  end
256
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
+
257
359
  # The `self.new` keyword for this field — a user-supplied input carrying the field's
258
360
  # value type. The `?` prefix marks the field omittable; its value type already carries
259
361
  # the schema's nullability, so nil is admitted only for a nullable field.
@@ -270,9 +372,10 @@ module BiDiGenerate
270
372
 
271
373
  # A generated immutable value type (a Serialization::Record.define(...) class). discriminator is the
272
374
  # 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
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
276
379
  def union? = false
277
380
  def nested_types = nested || []
278
381
 
@@ -323,7 +426,9 @@ module BiDiGenerate
323
426
  parts = []
324
427
  parts << "?#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
325
428
  parts.concat(fields.map(&:rbs_arg))
326
- parts << '?extensions: untyped' if extensible
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
327
432
  parts.join(', ')
328
433
  end
329
434
  end
@@ -347,14 +452,54 @@ module BiDiGenerate
347
452
  end
348
453
 
349
454
  # 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
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
352
464
  def union? = true
353
465
  def value_variants = variants.select { |v| v.mode == :value }
354
466
  def presence_variants = variants.select { |v| v.mode == :presence }
355
467
  def fallback_variant = variants.find { |v| v.mode == :fallback }
356
468
  def nested_types = nested || []
357
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
+
358
503
  # `discriminator 'wire'`, or `discriminator 'wire', {sym: 'token', …}` (wrapped when
359
504
  # long) carrying the inbound wire->symbol map for string-tagged variants.
360
505
  def discriminator_decl(indent)
@@ -364,18 +509,45 @@ module BiDiGenerate
364
509
 
365
510
  BiDiGenerate.wrap_call("#{head}, ", pairs, indent, open: '{', close: '}')
366
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
367
519
  end
368
520
 
369
- Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, keyword_init: true)
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)
370
535
 
371
536
  class Schema
372
537
  def initialize(schema)
373
538
  @types = schema['types']
374
539
  @commands = schema['commands']
375
540
  @events = schema['events']
541
+ @domains = schema['domains'] || {}
542
+ @vendor = schema['vendor'] || {}
376
543
  promote_command_params_records!
377
544
  end
378
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
+
379
551
  # A command written in CDDL map form carries its params as an *inline* object (rather
380
552
  # than the usual group form referencing a named params type). The projector links the
381
553
  # command to those params, but hoists them into a synthetic record owned by the
@@ -412,6 +584,86 @@ module BiDiGenerate
412
584
  @commands.select { |c| c['domain'] == domain }
413
585
  end
414
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
+
415
667
  def type_kind(ref)
416
668
  @types[ref]&.fetch('kind', nil)
417
669
  end
@@ -443,10 +695,17 @@ module BiDiGenerate
443
695
  next unless name.start_with?("#{domain}.")
444
696
 
445
697
  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)
698
+ Enum.new(constant_name: BiDiGenerate.screaming_snake(name.sub("#{domain}.", '')), pairs: pairs,
699
+ spec_href: type['specHref'])
447
700
  end
448
701
  end
449
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
+
450
709
  # Structured value classes (records + discriminated unions) declared under
451
710
  # "<domain>." Empty records are projector artifacts with nothing to carry, so
452
711
  # they stay opaque hashes; only non-empty records and unions become classes.
@@ -494,12 +753,43 @@ module BiDiGenerate
494
753
  resolved[:list] ? nil : resolved[:ref]
495
754
  end
496
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
+
497
760
  private
498
761
 
499
762
  def domain_path(name)
500
763
  name.include?('.') ? ruby_path(name) : nil
501
764
  end
502
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
+
503
793
  # Class path, nesting a synthetic type under its owner as `Owner::Label` so a ref
504
794
  # resolves to the same nested constant the type is emitted as.
505
795
  def ruby_path(name)
@@ -520,29 +810,33 @@ module BiDiGenerate
520
810
  nullable = node['nullable'] ? true : false
521
811
  if node.key?('list')
522
812
  element = resolve(node['list'])
523
- return {ref: element[:ref], list: true, nullable: nullable, rbs: nilable("Array[#{element[:rbs]}]", nullable)}
813
+ return {ref: element[:ref], list: true, nullable: nullable, scalar: element[:scalar],
814
+ rbs: nilable("Array[#{element[:rbs]}]", nullable)}
524
815
  end
525
816
  if node.key?('ref')
526
817
  named = resolve_named(node['ref'])
527
- return {ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable(named[:rbs], nullable)}
818
+ return {ref: named[:ref], list: named[:list], nullable: nullable, scalar: named[:scalar],
819
+ rbs: nilable(named[:rbs], nullable)}
528
820
  end
529
821
  return resolve_union(node, nullable) if node.key?('union')
530
822
 
531
- {ref: nil, list: false, nullable: nullable, primitive: checkable_primitive(node),
532
- rbs: nilable(scalar_rbs(node), nullable)}
823
+ {ref: nil, list: false, nullable: nullable, rbs: nilable(scalar_rbs(node), nullable)}
533
824
  end
534
825
 
535
826
  # 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.
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.
539
832
  def resolve_union(node, nullable)
540
833
  refs = node['union'].select { |arm| arm.key?('ref') }
541
834
  opaque = {ref: nil, list: false, nullable: nullable, rbs: nilable('untyped', nullable)}
542
835
  return opaque unless refs.one? && union_ref?(refs.first['ref'])
543
836
 
544
837
  named = resolve_named(refs.first['ref'])
545
- {ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable('untyped', nullable)}
838
+ {ref: named[:ref], list: named[:list], nullable: nullable, scalar: node['scalar'],
839
+ rbs: nilable('untyped', nullable)}
546
840
  end
547
841
 
548
842
  # True when a ref (following aliases) is a union — the only arm whose from_json tolerates a
@@ -569,7 +863,7 @@ module BiDiGenerate
569
863
 
570
864
  case type['kind']
571
865
  when 'record' then type['fields'].empty? ? OPAQUE : named_type(name)
572
- when 'union' then named_type(name)
866
+ when 'union' then named_union(name)
573
867
  when 'enum' then {ref: nil, list: false, rbs: 'Symbol'}
574
868
  when 'alias' then resolve_named_alias(name, type['type'], seen)
575
869
  else OPAQUE
@@ -583,13 +877,29 @@ module BiDiGenerate
583
877
  {ref: domain_path(name), list: false, rbs: rbs_abs(ruby_path(name))}
584
878
  end
585
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
+
586
896
  def resolve_named_alias(name, inner, seen)
587
- return named_type(name) if inner.key?('union')
897
+ return named_union(name) if inner.key?('union')
588
898
  return resolve_named(inner['ref'], seen) if inner.key?('ref')
589
899
 
590
900
  if inner.key?('list')
591
901
  element = resolve(inner['list'])
592
- return {ref: element[:ref], list: true, rbs: "Array[#{element[:rbs]}]"}
902
+ return {ref: element[:ref], list: true, scalar: element[:scalar], rbs: "Array[#{element[:rbs]}]"}
593
903
  end
594
904
 
595
905
  {ref: nil, list: false, rbs: scalar_rbs(inner)}
@@ -599,16 +909,27 @@ module BiDiGenerate
599
909
  flag ? BiDiGenerate.rbs_nilable(type) : type
600
910
  end
601
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
+
602
919
  def record_class(name, type)
603
920
  const = type['fields'].find { |f| baked_discriminator?(f) }
604
921
  discriminator = const && {ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(const['name'])),
605
922
  wire: const['wire'], value: const['type']['const'],
606
923
  rbs: rbs_const(const['type']['const'])}
607
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.
608
928
  TypeClass.new(ruby_name: BiDiGenerate.type_class_name(name), fields: fields,
609
929
  discriminator: discriminator, extensible: type['extensible'] ? true : false,
610
930
  schema_name: name, synthetic: type['synthetic'] ? true : false,
611
- owner: type['owner'], label: type['label'])
931
+ owner: type['owner'], label: type['label'], spec_href: type['specHref'],
932
+ **directionality(name))
612
933
  end
613
934
 
614
935
  # A const field is a baked discriminator tag, unless it is also nullable: the spec's
@@ -625,7 +946,41 @@ module BiDiGenerate
625
946
  FieldIR.new(ruby_name: ruby_name, wire_key: field['wire'],
626
947
  required: field['required'], nullable: resolved[:nullable],
627
948
  ref: resolved[:ref], list: resolved[:list], enum: enum_const(field['type']),
628
- primitive: resolved[:primitive], rbs: resolved[:rbs])
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)
629
984
  end
630
985
 
631
986
  def union_class(name)
@@ -635,7 +990,15 @@ module BiDiGenerate
635
990
  # order); consume it rather than re-deriving and silently depending on emit
636
991
  # order. An alias-to-union (only input.Origin) has no selector — its const-string
637
992
  # 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)
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
639
1002
  end
640
1003
 
641
1004
  # Map a union `selector` to dispatch variants the template renders:
@@ -658,7 +1021,9 @@ module BiDiGenerate
658
1021
  raise "union #{name} selector yielded no dispatch variants" if variants.empty?
659
1022
 
660
1023
  UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
661
- discriminator_wire: selector['by'], variants: variants, schema_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))
662
1027
  end
663
1028
 
664
1029
  def discriminated_variants(selector)
@@ -682,7 +1047,8 @@ module BiDiGenerate
682
1047
  # discriminator; the bare-string arms need no dispatch (Union.from_json returns a
683
1048
  # non-Hash payload unchanged). So dispatch the ref arms by their const tag.
684
1049
  def union_from_alias(name)
685
- consts = @types[name]['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref|
1050
+ spec = @types[name]
1051
+ consts = spec['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref|
686
1052
  const = @types[ref]['fields'].find { |f| f['type'].key?('const') }
687
1053
  const || raise("alias-union #{name} arm #{ref} has no const discriminator to dispatch on")
688
1054
  [ref, const]
@@ -690,8 +1056,13 @@ module BiDiGenerate
690
1056
  variants = consts.map do |ref, const|
691
1057
  VariantIR.new(mode: :value, value: const['type']['const'], ref: ruby_path(ref), requires: nil)
692
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).
693
1062
  UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
694
- discriminator_wire: consts.values.first['wire'], variants: variants, schema_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))
695
1066
  end
696
1067
 
697
1068
  def record_params(fields)
@@ -723,10 +1094,6 @@ module BiDiGenerate
723
1094
  # check fails open rather than a wrong strict default rejecting valid data.
724
1095
  CHECKABLE_PRIMITIVES = %w[string number integer boolean].freeze
725
1096
 
726
- def checkable_primitive(node)
727
- node['primitive'] if node.key?('primitive') && CHECKABLE_PRIMITIVES.include?(node['primitive'])
728
- end
729
-
730
1097
  # The leaf of +resolve+: the bare scalar type, before any nullable wrap. An alias's
731
1098
  # own nullable is intentionally left off — only the referencing node's is applied.
732
1099
  def scalar_rbs(node)
@@ -835,15 +1202,95 @@ module BiDiGenerate
835
1202
 
836
1203
  def self.build_ir(schema)
837
1204
  schema.domains.map do |domain|
838
- Module.new(
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(
839
1209
  name: domain,
840
1210
  ruby_class: snake_to_class_name(camel_to_snake(domain)),
841
1211
  filename: camel_to_snake(domain),
842
1212
  commands: schema.commands_for(domain).map { |cmd| build_command(schema, cmd) },
843
1213
  events: schema.events_for(domain).map { |ev| build_event(schema, ev) },
844
1214
  enums: schema.enums_for(domain),
845
- types: nest_synthetic(schema.types_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)
846
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
847
1294
  end
848
1295
  end
849
1296
 
@@ -864,7 +1311,8 @@ module BiDiGenerate
864
1311
  params: params,
865
1312
  result_ref: cmd['result'] && schema.structured_ref(cmd['result']['ref']),
866
1313
  params_class: params_class,
867
- union_params: params_kind == 'union'
1314
+ union_params: params_kind == 'union',
1315
+ spec_href: cmd['specHref']
868
1316
  )
869
1317
  end
870
1318
 
@@ -891,6 +1339,8 @@ module BiDiGenerate
891
1339
  end
892
1340
 
893
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')
894
1344
  ERB.new(File.read(template_path), trim_mode: '-').result(binding)
895
1345
  end
896
1346
 
@@ -901,6 +1351,40 @@ module BiDiGenerate
901
1351
 
902
1352
  emit(modules, output_dir, 'module.rb.erb', 'rb')
903
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) }
904
1388
  end
905
1389
 
906
1390
  # Renders every module through one template and writes the result into target,