[java][bidi] Add BiDi code generator#17777
Conversation
PR Summary by QodoAdd Bazel-based Java BiDi protocol code generator
AI Description
Diagram
High-Level Assessment
Files changed (21)
|
Code Review by Qodo
Context used✅ Tickets:
🎫 2.48 doesn't trigger javascript in link's href on click() 🎫 Instance ChroneDriver Error: ConnectFailure✅ Compliance rules (platform):
18 rules 1. Module.subscribe() missing Javadoc
|
| public final <X> String subscribe(Event<X> event, Consumer<X> handler) { | ||
| return handle.subscribe(event, handler); | ||
| } | ||
|
|
||
| protected final <X> String subscribe( | ||
| Event<X> event, Consumer<X> handler, SubscriptionScope scope) { | ||
| public final <X> String subscribe(Event<X> event, Consumer<X> handler, SubscriptionScope scope) { | ||
| return handle.subscribe(event, handler, scope); | ||
| } | ||
|
|
||
| protected final void unsubscribe(String subscriptionId) { | ||
| public final void unsubscribe(String subscriptionId) { | ||
| handle.unsubscribe(subscriptionId); |
There was a problem hiding this comment.
1. module.subscribe() missing javadoc 📘 Rule violation ✧ Quality
Module.subscribe(...) and Module.unsubscribe(...) are public methods but have no Javadoc comments. This violates the requirement that public API methods include Javadoc documentation.
Agent Prompt
## Issue description
`Module.subscribe(...)` and `Module.unsubscribe(...)` are public but lack required Javadoc.
## Issue Context
Compliance requires Javadoc for public API methods.
## Fix Focus Areas
- java/src/org/openqa/selenium/bidi/Module.java[48-57]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| /** | ||
| * Returns a function that deserializes a {@code Map<String, Object>} event payload into an | ||
| * instance of {@code type} via the Selenium JSON library (ConstructorCoercer). | ||
| */ | ||
| public static <T> Function<Map<String, Object>, T> fromMap(Class<T> type) { |
There was a problem hiding this comment.
2. frommap() javadoc missing tags 📘 Rule violation ✧ Quality
ConverterFunctions.fromMap(Class<T> type) has a Javadoc block but it lacks required @param and @return tags. This violates the requirement for complete Javadoc on public methods.
Agent Prompt
## Issue description
`ConverterFunctions.fromMap(...)` is public and has Javadoc, but the Javadoc is missing required tags (`@param type` and `@return`).
## Issue Context
Compliance requires complete Javadoc on public methods including parameter/return tags.
## Fix Focus Areas
- java/src/org/openqa/selenium/bidi/ConverterFunctions.java[39-52]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| private FieldInfo parseField(Map<String, Object> raw) { | ||
| String rawName = str(raw, "name"); | ||
| String wire = str(raw, "wire"); | ||
| // Escape Java reserved words used as field names in the spec (e.g. "this"). | ||
| String name = escapeReserved(rawName); | ||
| boolean required = Boolean.TRUE.equals(raw.get("required")); | ||
| @SuppressWarnings("unchecked") | ||
| Map<String, Object> type = (Map<String, Object>) raw.get("type"); | ||
| // wire key stays as the original spec name for JSON serialization | ||
| return new FieldInfo(name, wire != null ? wire : rawName, required, type); | ||
| } |
There was a problem hiding this comment.
3. Reserved-key deserialization breaks 🐞 Bug ≡ Correctness
BiDiGenerator escapes reserved JSON field names (e.g., wire key "this" becomes Java identifier "this_"), but record deserialization uses ConstructorCoercer which matches JSON keys to constructor parameter names. This causes required fields to fail deserialization (missing key) or optional fields to be silently ignored whenever wire name != Java parameter name.
Agent Prompt
## Issue description
Generated record classes escape reserved words in Java identifiers (e.g. `this` -> `this_`) while keeping the JSON wire key as the original (`"this"`). Selenium JSON deserialization via `ConstructorCoercer` requires JSON property keys to match constructor parameter names, so any field where `wire != name` will not deserialize correctly.
## Issue Context
- `ConstructorCoercer` uses `Parameter.getName()` and checks `properties.containsKey(parameter.getName())`, meaning it cannot map JSON key `"this"` to a parameter named `this_`.
- The BiDi protocol payloads do include a `"this"` key (e.g. Script callFunction params).
## Fix Focus Areas
- java/src/org/openqa/selenium/bidi/BiDiGenerator.java[1266-1276]
- java/src/org/openqa/selenium/bidi/BiDiGenerator.java[1108-1113]
## Suggested fix
In the generator, for any **record** type that has at least one field where `f.wire` differs from `f.name` (currently happens via `escapeReserved`), emit a `static fromJson(Map<String,Object>)` factory for that record.
- The method name must be exactly `fromJson` (so `StaticInitializerCoercer` picks it up).
- Inside `fromJson`, read values by **wire key** (`f.wire`), coerce each value to the correct Java type (recursing into nested records/unions/enums/lists/maps as needed), and invoke the all-fields constructor.
- Do **not** call `ConverterFunctions.fromMap(<ThisClass>.class)` inside this method (would recurse back into `fromJson`).
- Keep `toMap()` using wire keys as-is.
This preserves the escaped Java identifiers for API ergonomics while keeping JSON deserialization correct.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| private static void packToJar(Path tempDir, Path outputJar) throws IOException { | ||
| Files.createDirectories(outputJar.getParent()); | ||
| try (OutputStream os = Files.newOutputStream(outputJar); | ||
| JarOutputStream jos = new JarOutputStream(os)) { | ||
| Files.walkFileTree( | ||
| tempDir, | ||
| new SimpleFileVisitor<Path>() { | ||
| @Override | ||
| public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) | ||
| throws IOException { | ||
| String rel = tempDir.relativize(dir).toString().replace('\\', '/'); | ||
| if (!rel.isEmpty()) { | ||
| JarEntry e = new JarEntry(rel + "/"); | ||
| jos.putNextEntry(e); | ||
| jos.closeEntry(); | ||
| } | ||
| return FileVisitResult.CONTINUE; | ||
| } | ||
|
|
||
| @Override | ||
| public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) | ||
| throws IOException { | ||
| String rel = tempDir.relativize(file).toString().replace('\\', '/'); | ||
| JarEntry e = new JarEntry(rel); | ||
| jos.putNextEntry(e); | ||
| try (InputStream is = Files.newInputStream(file)) { | ||
| is.transferTo(jos); | ||
| } | ||
| jos.closeEntry(); | ||
| return FileVisitResult.CONTINUE; | ||
| } | ||
| }); |
There was a problem hiding this comment.
4. Srcjar not reproducible 🐞 Bug ☼ Reliability
packToJar writes bidi-generated.srcjar using Files.walkFileTree traversal order and default JarEntry metadata, which can vary across filesystems/runs. This makes the generated srcjar non-deterministic, reducing Bazel cache hits and harming reproducible builds.
Agent Prompt
## Issue description
The generator packs generated sources into a srcjar in a potentially non-deterministic way:
- `Files.walkFileTree` does not guarantee a stable traversal order.
- `JarEntry` is created without normalizing timestamps/metadata.
This can cause `bidi-generated.srcjar` to differ between builds even when input schema is unchanged.
## Issue Context
The srcjar is produced by a Bazel `genrule` and consumed directly as compilation input, so nondeterminism impacts caching and reproducibility.
## Fix Focus Areas
- java/src/org/openqa/selenium/bidi/BiDiGenerator.java[1365-1396]
- java/src/org/openqa/selenium/bidi/BUILD.bazel[59-65]
## Suggested fix
Update `packToJar` to:
1. Collect all files under `tempDir` with `Files.walk(tempDir)`.
2. Sort paths by their normalized jar entry name (`tempDir.relativize(p).toString().replace('\\','/')`).
3. For each entry, set deterministic metadata (at minimum `entry.setTime(0L)`; optionally also set method/extra fields deterministically).
4. Write entries in sorted order.
This yields stable srcjar bytes for the same generated content.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| private String serializeExpr(String varName, Map<String, Object> typeRef, String domain) { | ||
| if (typeRef == null) return varName; | ||
| if (typeRef.containsKey("primitive") || typeRef.containsKey("const")) return varName; | ||
| if (typeRef.containsKey("ref")) { | ||
| String resolvedKind = resolvedKindOf(str(typeRef, "ref")); | ||
| if ("enum".equals(resolvedKind)) return varName + ".toString()"; | ||
| if ("record".equals(resolvedKind) || "union".equals(resolvedKind)) { | ||
| return varName + ".toMap()"; | ||
| } | ||
| // alias: recurse through | ||
| Map<String, Object> aliasNode = types.get(str(typeRef, "ref")); | ||
| if (aliasNode != null && "alias".equals(str(aliasNode, "kind"))) { | ||
| @SuppressWarnings("unchecked") | ||
| Map<String, Object> inner = (Map<String, Object>) aliasNode.get("type"); | ||
| return serializeExpr(varName, inner, domain); | ||
| } | ||
| return varName; | ||
| } | ||
| if (typeRef.containsKey("list")) { | ||
| @SuppressWarnings("unchecked") | ||
| Map<String, Object> elem = (Map<String, Object>) typeRef.get("list"); | ||
| String elemKind = resolvedKindFromTypeRef(elem); | ||
| if ("enum".equals(elemKind)) { | ||
| return varName | ||
| + ".stream().map(Object::toString)" | ||
| + ".collect(java.util.stream.Collectors.toList())"; | ||
| } | ||
| if ("record".equals(elemKind) || "union".equals(elemKind)) { | ||
| return varName | ||
| + ".stream().map(e -> e.toMap())" | ||
| + ".collect(java.util.stream.Collectors.toList())"; | ||
| } | ||
| return varName; | ||
| } | ||
| return varName; | ||
| } |
There was a problem hiding this comment.
5. Map fields serialize wrong 🐞 Bug ≡ Correctness
serializeExpr handles primitives/refs/lists but not map type refs, so toMap() will emit Map<String,T> values without converting nested record/union/enum values to their wire form. Any sender-side schema field with map values of complex types will generate malformed command payloads.
Agent Prompt
## Issue description
`serializeExpr` is used by generated `toMap()` methods to convert nested values into wire-compatible shapes. It supports primitives/const, refs (enum/record/union), and lists, but it does not handle `map` typeRefs.
As a result, fields typed as `Map<String, SomeRecord>` / `Map<String, SomeUnion>` / `Map<String, SomeEnum>` will be put on the wire as Java objects rather than `Map<String,Object>` / strings, leading to malformed payloads.
## Issue Context
The shared schema vocabulary explicitly includes `map` type refs, and the generator already resolves them to `java.util.Map<String, ...>` types.
## Fix Focus Areas
- java/src/org/openqa/selenium/bidi/BiDiGenerator.java[1164-1199]
- java/src/org/openqa/selenium/bidi/BiDiGenerator.java[1053-1057]
## Suggested fix
Extend `serializeExpr` with a `typeRef.containsKey("map")` branch:
- Determine the resolved kind of the map value type.
- For enum values: map `v -> v.toString()`.
- For record/union values: map `v -> v.toMap()`.
- For nested list/map values: recurse similarly.
- Preserve key strings unchanged.
Example emitted expression shape (use LinkedHashMap to preserve order):
`varName.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> <serializedValueExpr>, (a,b)->b, LinkedHashMap::new))`
This makes `toMap()` correct for map-typed schema fields.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
I started reviewing this and ended up creating #17786 |
🔗 Related Issues
💥 What does this PR do?
Adds the BiDi Java client generator: reads the shared, binding-neutral BiDi schema and emits the full typed Java BiDi protocol layer — module classes (one per domain, with commands and events) plus supporting POJOs, enums, and discriminated unions — across all 14 domains (~79 commands, 27 events, 298 generated classes total), on top of the hand-written seam (Module base class + Handle) that makes it usable.
Shape of a generated module class (real output, network.Network trimmed to 2 events + 2 commands — the full file has 5 events and 13 commands):
Usage is symmetric regardless of whether a command has a result:
🔧 Implementation Notes
Autogenerated code and not checked in
The build compiles the genrule's srcjar directly:
No generated
.javais ever committed.Trade-off accepted knowingly: less reviewable diff surface for generator changes. Given Java has a lot of generated classes, strict typing means one
.javafile per record/enum/union, not a handful of dynamically-typed modules like the JS or Python bindings. The actual generated code never appears in this PR's diff, only the generator that produces it.How to see the generated output since it's not in the diff, pull it out locally:
bazel build //java/src/org/openqa/selenium/bidi:bidi-generated mkdir -p /tmp/bidi-generated-src unzip -o -q bazel-bin/java/src/org/openqa/selenium/bidi/bidi-generated.srcjar -d /tmp/bidi-generated-src open /tmp/bidi-generated-src # or just browse/grep it298 files across 14 domains will land there. A few worth spot-checking first, each demonstrating a specific decision from the Implementation Notes above:
org/openqa/selenium/bidi/protocol/module/Network.java— a representative module class (commands + events)org/openqa/selenium/bidi/protocol/browsingcontext/SetViewportParameters.java— explicit-null vs. omitted-key handling side by side (viewport/devicePixelRatioare nullable,context/userContextsaren't)org/openqa/selenium/bidi/protocol/emulation/SetTimezoneOverrideParameters.java— required-but-nullable field on a sender typeorg/openqa/selenium/bidi/protocol/script/PrimitiveProtocolValue.javaandStringValue.java— theinterface extends/implementschain for multi-parent unionsIf you'd rather regenerate directly without the Bazel packaging step (useful for diffing output after a generator change):
bazel build //java/src/org/openqa/selenium/bidi:bidi-client-generator bazel run //java/src/org/openqa/selenium/bidi:bidi-client-generator -- \ "$(bazel info bazel-genfiles)/javascript/selenium-webdriver/create-bidi-src_schema.json" \ /tmp/bidi-out.srcjar unzip -o -q /tmp/bidi-out.srcjar -d /tmp/bidi-generated-src(Direct invocation of the built binary without
bazel runfails withCannot locate runfiles directory— it needsbazel runto set up its runfiles tree.)Explicit-null vs. omitted-key tracking
Tracked per-field for fields the schema marks nullable, so
toMap()can distinguish:"field": null→ clear itThis is gated strictly on the schema's own
nullableflag, not applied blanket to every optional field.Required-but-nullable fields
@Nullableon their constructor parameter (boxed, if the field would otherwise be a Java primitive).ConstructorCoercerwas extended to honor jspecify's@Nullable(previously onlyOptional<T>was treated as nullable).🤖 AI assistance
💡 Additional Considerations
Test coverage is not uniform across domains.
Only network, log, script, and browsingContext have dedicated generated-code tests (unit and/or browser-integration) added in this PR.
session, browser, emulation, storage, input, webExtension, permissions, speculation, bluetooth, userAgentClientHints have none yet.
Essentially, the modules that are needed for implementing high-level APIs for Selenium 5 have tests.
Extensible/unknown-key round-tripping (unrecognized JSON keys silently dropped) is a known, scoped gap, not implemented in this PR. Not implemented because ConstructorCoercer maps JSON keys 1:1 to constructor parameters and silently drops anything unmatched — supporting round-trip needs a genuinely different deserialization path (capturing unrecognized keys into a side bag alongside the typed fields), which is new mechanism work, and fairly big. So this is something we might not support in Java at all. We can see how much this is required and decide later.
Rest, will be added in a follow up PR.
🔄 Types of changes