diff --git a/java/spotbugs-excludes.xml b/java/spotbugs-excludes.xml index 07688d7151c87..fe9b73b2aeeec 100644 --- a/java/spotbugs-excludes.xml +++ b/java/spotbugs-excludes.xml @@ -33,6 +33,11 @@ + + + + + diff --git a/java/src/org/openqa/selenium/bidi/BUILD.bazel b/java/src/org/openqa/selenium/bidi/BUILD.bazel index 144b60ffc43c1..75ec09fa93724 100644 --- a/java/src/org/openqa/selenium/bidi/BUILD.bazel +++ b/java/src/org/openqa/selenium/bidi/BUILD.bazel @@ -1,9 +1,13 @@ -load("//java:defs.bzl", "java_library") +load("//java:defs.bzl", "java_binary", "java_library") AUGMENTER_SRCS = [ "BiDiProvider.java", ] +GENERATOR_SRCS = [ + "BiDiGenerator.java", +] + java_library( name = "augmenter", srcs = AUGMENTER_SRCS, @@ -26,7 +30,7 @@ java_library( [ "*.java", ], - exclude = AUGMENTER_SRCS, + exclude = AUGMENTER_SRCS + GENERATOR_SRCS, ), visibility = [ "//java/src/org/openqa/selenium/bidi:__subpackages__", @@ -42,3 +46,45 @@ java_library( "@maven//:org_jspecify_jspecify", ], ) + +java_binary( + name = "bidi-client-generator", + srcs = GENERATOR_SRCS, + main_class = "org.openqa.selenium.bidi.BiDiGenerator", + deps = [ + "//java/src/org/openqa/selenium/json", + ], +) + +genrule( + name = "generate-bidi", + srcs = ["//javascript/selenium-webdriver:create-bidi-src_schema"], + outs = ["bidi-generated.srcjar"], + cmd = "\"$(execpath :bidi-client-generator)\" \"$(location //javascript/selenium-webdriver:create-bidi-src_schema)\" \"$@\"", + tools = [":bidi-client-generator"], +) + +# Autogen, not checked in: the build compiles :generate-bidi's srcjar directly, nothing is +# hand-edited, every build ships fresh output. The cross-binding BiDi codegen decisions doc (G1) +# calls checking-in-while-unproven the general recommendation, but also names this exact shape — +# build-time generation straight into bazel-out — as the end state, and notes selenium-devtools/CDP +# in this repo already works this way. Matching that existing, already-de-risked precedent avoids +# standing up a separate checked-in + verify-test workflow (with its own CI-coverage question to +# resolve first) just for BiDi. Trade-off accepted knowingly: less reviewable diff surface while the +# generator is still new. +java_library( + name = "bidi-generated", + srcs = [":generate-bidi"], + visibility = [ + "//java/src/org/openqa/selenium/bidi:__subpackages__", + "//java/src/org/openqa/selenium/remote:__pkg__", + "//java/test/org/openqa/selenium/bidi:__subpackages__", + "//java/test/org/openqa/selenium/grid:__subpackages__", + ], + deps = [ + ":bidi", + "//java/src/org/openqa/selenium:core", + "//java/src/org/openqa/selenium/json", + "@maven//:org_jspecify_jspecify", + ], +) diff --git a/java/src/org/openqa/selenium/bidi/BiDiGenerator.java b/java/src/org/openqa/selenium/bidi/BiDiGenerator.java new file mode 100644 index 0000000000000..4f69d846b7895 --- /dev/null +++ b/java/src/org/openqa/selenium/bidi/BiDiGenerator.java @@ -0,0 +1,1949 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.openqa.selenium.json.Json; + +/** + * Generates Java BiDi module classes and their supporting POJOs from the flat binding-neutral + * {@code bidi_schema.json} produced by {@code project_bidi_schema.mjs}. + * + *

Usage: {@code BiDiGenerator } + */ +public class BiDiGenerator { + + private static final String BASE_PKG = "org.openqa.selenium.bidi"; + + // Java reserved words that cannot appear as method names; append "_" to escape. + private static final Set JAVA_RESERVED = + new java.util.HashSet<>( + java.util.Arrays.asList( + "abstract", + "assert", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "default", + "do", + "double", + "else", + "enum", + "extends", + "final", + "finally", + "float", + "for", + "goto", + "if", + "implements", + "import", + "instanceof", + "int", + "interface", + "long", + "native", + "new", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "strictfp", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "try", + "void", + "volatile", + "while")); + + private static final String API_JAVADOC = + "/**\n" + + " * This is an unsupported API. No compatibility guarantees are provided.\n" + + " * It tracks the W3C WebDriver BiDi specification directly. As the specification\n" + + " * evolves, this API will change or be removed without prior notice.\n" + + " */\n"; + + private static final String LICENSE = + "// Licensed to the Software Freedom Conservancy (SFC) under one\n" + + "// or more contributor license agreements. See the NOTICE file\n" + + "// distributed with this work for additional information\n" + + "// regarding copyright ownership. The SFC licenses this file\n" + + "// to you under the Apache License, Version 2.0 (the\n" + + "// \"License\"); you may not use this file except in compliance\n" + + "// with the License. You may obtain a copy of the License at\n" + + "//\n" + + "// http://www.apache.org/licenses/LICENSE-2.0\n" + + "//\n" + + "// Unless required by applicable law or agreed to in writing,\n" + + "// software distributed under the License is distributed on an\n" + + "// \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n" + + "// KIND, either express or implied. See the License for the\n" + + "// specific language governing permissions and limitations\n" + + "// under the License.\n\n" + + "// This file is generated. Do not edit — regenerate via BiDiGenerator.\n\n"; + + public static void main(String[] args) throws IOException { + if (args.length != 2) { + System.err.println("Usage: BiDiGenerator "); + System.exit(1); + } + + Path schemaFile = Paths.get(args[0]); + Path outputJar = Paths.get(args[1]).toAbsolutePath(); + + String schemaText = new String(Files.readAllBytes(schemaFile), UTF_8); + @SuppressWarnings("unchecked") + Map schema = (Map) new Json().toType(schemaText, Json.MAP_TYPE); + + Path tempDir = Files.createTempDirectory("bidi-generated"); + try { + new Generator(schema).generateAll(tempDir); + packToJar(tempDir, outputJar); + } finally { + deleteRecursive(tempDir); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Generator + // ═══════════════════════════════════════════════════════════════ + + private static class Generator { + + private final Map schema; + private final Map> types = new LinkedHashMap<>(); + + /** Types reachable from command/event params and results — the only ones that get generated. */ + private final Set reachable; + + /** Types reachable from command params — the only ones that need toMap(). */ + private final Set senderTypes; + + /** + * Types reachable from command results or event params — i.e. types a caller can receive. A + * type in both this set and {@code senderTypes} is used bidirectionally (e.g. + * script.SharedReference: built to send as a script argument, and also received inside a remote + * value) and is the only case that needs an immutable value class plus a separate Builder — see + * {@link #appendBuilder}. + */ + private final Set receivableTypes; + + /** + * Maps a variant record/union name to every parent union it belongs to. A type can genuinely + * belong to more than one union at once (e.g. PrimitiveProtocolValue is a member of both + * RemoteValue and LocalValue) — unions are generated as interfaces specifically so this is a + * real "implements/extends more than one" relationship, not something that has to be dropped. + */ + private final Map> variantParent; + + /** + * Synthetic types (anonymous CDDL constructs hoisted by the normalizer) keyed by their owner + * type name. They are emitted as nested static classes instead of top-level files. + */ + private final Map> syntheticChildren; + + @SuppressWarnings("unchecked") + Generator(Map schema) { + this.schema = schema; + Map rawTypes = (Map) schema.get("types"); + if (rawTypes != null) { + for (Map.Entry entry : rawTypes.entrySet()) { + types.put(entry.getKey(), (Map) entry.getValue()); + } + } + List> commands = + Optional.ofNullable((List>) schema.get("commands")) + .orElse(Collections.emptyList()); + List> events = + Optional.ofNullable((List>) schema.get("events")) + .orElse(Collections.emptyList()); + this.reachable = computeReachable(commands, events); + this.senderTypes = computeSenderTypes(); + this.receivableTypes = computeReceivableTypes(); + this.variantParent = computeVariantParent(); + this.syntheticChildren = computeSyntheticChildren(); + } + + @SuppressWarnings("unchecked") + private Map> computeSyntheticChildren() { + Map> result = new LinkedHashMap<>(); + for (Map.Entry> e : types.entrySet()) { + Map node = e.getValue(); + if (!Boolean.TRUE.equals(node.get("synthetic"))) continue; + String owner = str(node, "owner"); + if (owner != null) { + result.computeIfAbsent(owner, k -> new ArrayList<>()).add(e.getKey()); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private Set computeSenderTypes() { + List> commands = + Optional.ofNullable((List>) schema.get("commands")) + .orElse(Collections.emptyList()); + Set result = new LinkedHashSet<>(); + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + for (Map cmd : commands) { + seedRef(mapField(cmd, "params"), queue, result); + } + while (!queue.isEmpty()) { + String name = queue.poll(); + Map node = types.get(name); + if (node == null) continue; + collectRefs(node, queue, result); + if (Boolean.TRUE.equals(node.get("synthetic"))) { + String owner = str(node, "owner"); + if (owner != null && result.add(owner)) queue.add(owner); + } + // Union variants that extend a senderType union must also implement toMap(). + // Include them so appendRecordBody generates the override. + if ("union".equals(str(node, "kind"))) { + List variants = (List) node.get("variants"); + if (variants != null) { + for (String v : variants) { + if (result.add(v)) queue.add(v); + } + } + Map sel = mapField(node, "selector"); + if (sel != null) { + List> svs = (List>) sel.get("variants"); + if (svs != null) { + for (Map sv : svs) { + String ref = str(sv, "ref"); + if (ref != null && result.add(ref)) queue.add(ref); + } + } + String def = str(sel, "default"); + if (def != null && result.add(def)) queue.add(def); + } + } + } + return result; + } + + @SuppressWarnings("unchecked") + private Set computeReceivableTypes() { + List> commands = + Optional.ofNullable((List>) schema.get("commands")) + .orElse(Collections.emptyList()); + List> events = + Optional.ofNullable((List>) schema.get("events")) + .orElse(Collections.emptyList()); + Set result = new LinkedHashSet<>(); + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + for (Map cmd : commands) { + seedRef(mapField(cmd, "result"), queue, result); + } + for (Map evt : events) { + seedRef(mapField(evt, "params"), queue, result); + } + while (!queue.isEmpty()) { + String name = queue.poll(); + Map node = types.get(name); + if (node == null) continue; + collectRefs(node, queue, result); + if (Boolean.TRUE.equals(node.get("synthetic"))) { + String owner = str(node, "owner"); + if (owner != null && result.add(owner)) queue.add(owner); + } + if ("union".equals(str(node, "kind"))) { + List variants = (List) node.get("variants"); + if (variants != null) { + for (String v : variants) { + if (result.add(v)) queue.add(v); + } + } + Map sel = mapField(node, "selector"); + if (sel != null) { + List> svs = (List>) sel.get("variants"); + if (svs != null) { + for (Map sv : svs) { + String ref = str(sv, "ref"); + if (ref != null && result.add(ref)) queue.add(ref); + } + } + String def = str(sel, "default"); + if (def != null && result.add(def)) queue.add(def); + } + } + } + return result; + } + + @SuppressWarnings("unchecked") + private Map> computeVariantParent() { + Map> result = new LinkedHashMap<>(); + for (Map.Entry> e : types.entrySet()) { + String unionName = e.getKey(); + Map node = e.getValue(); + if (!"union".equals(str(node, "kind"))) continue; + List variants = (List) node.get("variants"); + if (variants == null) continue; + for (String variant : variants) { + result.computeIfAbsent(variant, k -> new ArrayList<>()).add(unionName); + } + } + return result; + } + + /** Reachable parent unions for {@code typeName}, in declaration order. */ + private List reachableParents(String typeName) { + return variantParent.getOrDefault(typeName, Collections.emptyList()).stream() + .filter(reachable::contains) + .collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + void generateAll(Path outDir) throws IOException { + List> commands = + Optional.ofNullable((List>) schema.get("commands")) + .orElse(Collections.emptyList()); + List> events = + Optional.ofNullable((List>) schema.get("events")) + .orElse(Collections.emptyList()); + + Map>> cmdByDomain = groupByDomain(commands); + Map>> evtByDomain = groupByDomain(events); + + Set domains = new LinkedHashSet<>(); + domains.addAll(cmdByDomain.keySet()); + domains.addAll(evtByDomain.keySet()); + + for (String domain : domains) { + generateModule( + domain, + cmdByDomain.getOrDefault(domain, Collections.emptyList()), + evtByDomain.getOrDefault(domain, Collections.emptyList()), + outDir); + } + + for (Map.Entry> entry : types.entrySet()) { + String name = entry.getKey(); + if (!reachable.contains(name)) continue; + + Map node = entry.getValue(); + // Synthetic types are emitted as nested static classes inside their owner's file. + if (Boolean.TRUE.equals(node.get("synthetic"))) continue; + + String kind = str(node, "kind"); + + if ("record".equals(kind)) { + generateRecord(name, node, outDir); + } else if ("enum".equals(kind)) { + generateEnum(name, node, outDir); + } else if ("union".equals(kind)) { + Map selector = mapField(node, "selector"); + if (selector == null || !Boolean.TRUE.equals(selector.get("correlated"))) { + generateUnion(name, node, outDir); + } + // correlated unions are protocol-internal; skip code generation + } + // "alias" → resolved inline, no class generated + } + } + + /** BFS over the type graph seeded by direct params/result refs from commands and events. */ + @SuppressWarnings("unchecked") + private Set computeReachable( + List> commands, List> events) { + Set reachable = new LinkedHashSet<>(); + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + + for (Map cmd : commands) { + seedRef(mapField(cmd, "params"), queue, reachable); + seedRef(mapField(cmd, "result"), queue, reachable); + } + for (Map evt : events) { + seedRef(mapField(evt, "params"), queue, reachable); + } + + while (!queue.isEmpty()) { + String name = queue.poll(); + Map node = types.get(name); + if (node == null) continue; + collectRefs(node, queue, reachable); + // Synthetic types are nested inside their owner — the owner must also be generated. + if (Boolean.TRUE.equals(node.get("synthetic"))) { + String owner = str(node, "owner"); + if (owner != null && reachable.add(owner)) queue.add(owner); + } + // Union variant strings are not covered by collectRefs (which only follows {ref:...} maps). + // Add them explicitly so discriminated-dispatch targets are generated. + if ("union".equals(str(node, "kind"))) { + @SuppressWarnings("unchecked") + List variants = (List) node.get("variants"); + if (variants != null) { + for (String v : variants) { + if (reachable.add(v)) queue.add(v); + } + } + @SuppressWarnings("unchecked") + Map sel = (Map) node.get("selector"); + if (sel != null) { + @SuppressWarnings("unchecked") + List> svs = (List>) sel.get("variants"); + if (svs != null) { + for (Map sv : svs) { + String ref = str(sv, "ref"); + if (ref != null && reachable.add(ref)) queue.add(ref); + } + } + String def = str(sel, "default"); + if (def != null && reachable.add(def)) queue.add(def); + } + } + } + return reachable; + } + + // A command/event's params or result is not always a direct {"ref": ...} — it can be a + // container wrapping one, e.g. {"list": {"ref": "test.Item"}} for a command whose result is + // directly a list of records (see resolveCommandResultArg). Delegating to collectRefs, which + // already recurses through arbitrarily nested Map/List structures looking for "ref" keys, + // seeds those nested types too instead of only ever finding a ref at the very top level. + private static void seedRef( + Map typeRef, java.util.ArrayDeque queue, Set reachable) { + if (typeRef == null) return; + collectRefs(typeRef, queue, reachable); + } + + @SuppressWarnings("unchecked") + private static void collectRefs( + Object node, java.util.ArrayDeque queue, Set reachable) { + if (node instanceof Map) { + Map m = (Map) node; + String ref = str(m, "ref"); + if (ref != null && reachable.add(ref)) queue.add(ref); + for (Object v : m.values()) collectRefs(v, queue, reachable); + } else if (node instanceof List) { + for (Object item : (List) node) collectRefs(item, queue, reachable); + } + } + + // ─── Module class ───────────────────────────────────────────── + + private void generateModule( + String domain, + List> commands, + List> events, + Path outDir) + throws IOException { + + // Module classes live in bidi.protocol.module (not bidi.protocol.{domain}), deliberately + // kept separate from the hand-written bidi.module package so generated and hand-written + // facades never collide on package or class name during the migration. + String pkg = "org.openqa.selenium.bidi.protocol.module"; + // Use "" as context domain so all POJO type refs in the module class are fully + // qualified (they live in bidi.{domain}, which never matches ""). + String moduleDomain = ""; + String cls = capitalize(domain); + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE); + sb.append("package ").append(pkg).append(";\n\n"); + sb.append("import org.openqa.selenium.Beta;\n"); + sb.append("import org.openqa.selenium.WebDriver;\n"); + sb.append("import org.openqa.selenium.bidi.Command;\n"); + sb.append("import org.openqa.selenium.bidi.ConverterFunctions;\n"); + sb.append("import org.openqa.selenium.bidi.Event;\n"); + sb.append("import org.openqa.selenium.bidi.Module;\n"); + sb.append("\n"); + sb.append(API_JAVADOC); + sb.append("@Beta\n"); + sb.append("@SuppressWarnings(\"unchecked\")\n"); + sb.append("public class ").append(cls).append(" extends Module {\n\n"); + + // Static Event constants + for (Map evt : events) { + String method = str(evt, "method"); + String evtName = str(evt, "name"); + Map paramsRef = mapField(evt, "params"); + String evtConstant = toConstantName(evtName); + + if (paramsRef != null) { + String javaType = resolveJavaType(paramsRef, moduleDomain, true); + String mapper = resolveEventMapper(paramsRef, moduleDomain); + sb.append(" public static final Event<") + .append(javaType) + .append("> ") + .append(evtConstant) + .append(" =\n") + .append(" new Event<>(\"") + .append(method) + .append("\", ") + .append(mapper) + .append(");\n\n"); + } else { + sb.append(" public static final Event ") + .append(evtConstant) + .append(" =\n") + .append(" new Event<>(\"") + .append(method) + .append("\", map -> null);\n\n"); + } + } + + sb.append(" public ").append(cls).append("(WebDriver driver) {\n"); + sb.append(" super(driver);\n"); + sb.append(" }\n\n"); + + // Command methods + for (Map cmd : commands) { + String method = str(cmd, "method"); + String cmdName = escapeReserved(str(cmd, "name")); + Map paramsRef = mapField(cmd, "params"); + Map resultRef = mapField(cmd, "result"); + + String returnType; + String resultArg; + if (resultRef == null) { + returnType = "void"; + resultArg = null; + } else { + returnType = resolveJavaType(resultRef, moduleDomain, true); + resultArg = resolveCommandResultArg(resultRef, moduleDomain); + } + + String paramsArgDecl = ""; + String paramsMapExpr = "java.util.Collections.emptyMap()"; + if (paramsRef != null) { + String paramsType = resolveJavaType(paramsRef, moduleDomain, true); + paramsArgDecl = paramsType + " params"; + paramsMapExpr = "params.toMap()"; + } + + sb.append(" public ") + .append(returnType) + .append(" ") + .append(cmdName) + .append("(") + .append(paramsArgDecl) + .append(") {\n"); + + if ("void".equals(returnType)) { + sb.append(" send(new Command<>(\"") + .append(method) + .append("\", ") + .append(paramsMapExpr) + .append("));\n"); + } else if (resultArg != null) { + sb.append(" return send(new Command<>(\"") + .append(method) + .append("\", ") + .append(paramsMapExpr) + .append(", ") + .append(resultArg) + .append("));\n"); + } else { + sb.append(" return send(new Command<>(\"") + .append(method) + .append("\", ") + .append(paramsMapExpr) + .append("));\n"); + } + sb.append(" }\n\n"); + } + + sb.append("}\n"); + + writeFile(outDir, pkg.replace('.', '/') + "/" + cls + ".java", sb.toString()); + } + + // ─── Record POJO ────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private void generateRecord(String typeName, Map node, Path outDir) + throws IOException { + + String domain = domainOf(typeName); + String pkg = domainPackage(domain); + String cls = simpleNameOf(typeName); + boolean needsToMap = senderTypes.contains(typeName); + boolean isReceivable = receivableTypes.contains(typeName); + boolean extensible = Boolean.TRUE.equals(node.get("extensible")); + List> rawFields = + (List>) + Optional.ofNullable(node.get("fields")).orElse(Collections.emptyList()); + boolean hasReservedWordField = + rawFields.stream().map(this::parseField).anyMatch(f -> !f.name.equals(f.wire)); + // A receivable, non-extensible type deserializes through ConstructorCoercer (no generated + // fromJson of its own, unless a reserved-word field forces one — that bypasses + // ConstructorCoercer entirely via StaticInitializerCoercer, so the annotation would be + // inert there). Everywhere else, this opts the type into the "warn instead of silently + // ignoring" half of undeclared-field handling — extensible types don't need it, since they + // capture rather than drop. + boolean warnsOnUnknownFields = isReceivable && !extensible && !hasReservedWordField; + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE); + sb.append("package ").append(pkg).append(";\n\n"); + // Collections/LinkedHashMap/Set are imported unconditionally rather than gated on + // needsToMap: a nested synthetic class in this same file may independently need them for + // toMap() or an extensible type's extras map (see appendRecordBody), and computing that + // file-wide isn't worth it against a harmless unused import. + sb.append("import java.util.Collections;\n"); + sb.append("import java.util.LinkedHashMap;\n"); + // Always imported: needed by fromJson() (see appendFromJson) whenever this class, or any + // nested synthetic class in this file, has an escaped-reserved-word field. + sb.append("import java.util.Map;\n"); + sb.append("import java.util.Objects;\n"); + sb.append("import java.util.Optional;\n"); + sb.append("import java.util.Set;\n"); + sb.append("import org.jspecify.annotations.Nullable;\n"); + sb.append("import org.openqa.selenium.Beta;\n"); + // BiDiException is needed by nested enum fromString() methods and union fromMap() methods. + sb.append("import org.openqa.selenium.bidi.BiDiException;\n"); + sb.append("import org.openqa.selenium.json.Json;\n"); + sb.append("import org.openqa.selenium.json.TypeToken;\n"); + sb.append("import org.openqa.selenium.json.WarnOnUnknownFields;\n\n"); + sb.append(API_JAVADOC); + sb.append("@Beta\n"); + if (warnsOnUnknownFields) { + sb.append("@WarnOnUnknownFields\n"); + } + // Unions are generated as interfaces, so a type belonging to more than one union (e.g. + // PrimitiveProtocolValue in both RemoteValue and LocalValue) genuinely implements all of + // them — no single-inheritance conflict to work around. + List parentUnionRefs = reachableParents(typeName); + String implementsClause = + parentUnionRefs.isEmpty() + ? "" + : " implements " + + parentUnionRefs.stream() + .map(r -> resolveRefToJavaClass(r, domain)) + .collect(Collectors.joining(", ")); + sb.append("public class ").append(cls).append(implementsClause).append(" {\n\n"); + + appendRecordBody(sb, typeName, node, domain, " "); + appendNestedSynthetics(sb, typeName, domain, " "); + + sb.append("}\n"); + writeFile(outDir, pkg.replace('.', '/') + "/" + cls + ".java", sb.toString()); + } + + @SuppressWarnings("unchecked") + private void appendRecordBody( + StringBuilder sb, String typeName, Map node, String domain, String m) { + + String cls = + Boolean.TRUE.equals(node.get("synthetic")) ? str(node, "label") : simpleNameOf(typeName); + List> rawFields = + (List>) + Optional.ofNullable(node.get("fields")).orElse(Collections.emptyList()); + List fields = + rawFields.stream().map(this::parseField).collect(Collectors.toList()); + List required = + fields.stream().filter(f -> f.required).collect(Collectors.toList()); + List optional = + fields.stream().filter(f -> !f.required).collect(Collectors.toList()); + boolean needsToMap = senderTypes.contains(typeName); + boolean isReceivable = receivableTypes.contains(typeName); + // An extra field may be sent only on an extensible type that is itself sendable, and an + // extensible, receivable type must preserve an undeclared field rather than drop it. The + // two are independent — a type can need either, both, or neither. Either one means a + // caller-built instance is not fully known up front, exactly like an optional field, so it + // folds into hasOptionals below and reuses the same sender-mutable / receiver-immutable / + // bidirectional-Builder split. + boolean extensible = Boolean.TRUE.equals(node.get("extensible")); + boolean needsExtrasSend = extensible && needsToMap; + boolean needsExtrasCapture = extensible && isReceivable; + boolean needsExtras = needsExtrasSend || needsExtrasCapture; + boolean hasOptionals = !optional.isEmpty() || needsExtras; + List parentUnionRefs = reachableParents(typeName); + + // A type reachable only from outbound command params (senderTypes) is caller-owned start + // to finish, so it keeps a single mutable class with fluent setters (the "else" branch + // below, unchanged from before). A type reachable only from inbound results/events is + // simplified the other way: since the caller never builds one, it gets no setters and no + // public constructor at all — only the deserializer's. A type reachable from *both* is the + // only case that must not expose mutation on a received instance: it becomes a fully + // immutable value class, with a separate nested Builder (see appendBuilder) as the only way + // to construct an outbound instance. + boolean immutable = hasOptionals && isReceivable; + boolean needsBuilder = immutable && needsToMap; + + // Nullable optional fields need a Set presence flag (see below); a type with a + // Builder needs a way for build() to state that flag explicitly rather than have it + // re-derived from Optional.isPresent() (which cannot tell "explicitly set to null" apart + // from "never set" — see appendConstructorAssignment). + List nullableOptional = + optional.stream().filter(f -> isNullable(f.typeRef)).collect(Collectors.toList()); + + // Fields — an immutable type's fields (including their xSet presence flags) are final: + // assigned once, in the single deserialization constructor, never mutated afterward. + for (FieldInfo f : fields) { + String jt = fieldJavaType(f, domain); + sb.append(m) + .append("private ") + .append(f.required || immutable ? "final " : "") + .append(jt) + .append(" ") + .append(f.name) + .append(";\n"); + if (!f.required && isNullable(f.typeRef)) { + // Tracks whether the field was ever explicitly set, independent of the value — + // Optional alone cannot distinguish "never set" from "explicitly set to null", + // and toMap() needs that distinction to send an explicit null on the wire. Only needed + // for fields the schema actually declares nullable; an optional field whose type is + // never null-able has no legal "explicit null" wire state to represent. + sb.append(m) + .append("private ") + .append(immutable ? "final " : "") + .append("boolean ") + .append(f.name) + .append("Set;\n"); + } + } + // The "junk drawer" for a field the spec doesn't declare. Final and constructor-populated + // whenever the type is receivable — its only value on a deserialized instance comes off + // the wire; otherwise it is caller-populated directly via addExtension. + if (needsExtras) { + sb.append(m) + .append("private final Map extensions") + .append(needsExtrasCapture ? ";\n" : " = new LinkedHashMap<>();\n"); + // Every field this type declares, by wire key — the line between "declared" (typed + // field) and "extra" (goes in the map above), used by both the outbound collision check + // (addExtension) and the inbound capture (fromJson). + sb.append(m).append("private static final Set DEFINED_FIELDS =\n"); + sb.append(m) + .append(" Set.of(") + .append( + fields.stream().map(f -> "\"" + f.wire + "\"").collect(Collectors.joining(", "))) + .append(");\n"); + } + if (!fields.isEmpty() || needsExtras) sb.append("\n"); + + // User-facing constructor (required fields only) — sender-only types alone; an immutable + // type has no way to be constructed except its Builder (if it has one) or the deserializer. + if (hasOptionals && !immutable) { + sb.append(m).append("public ").append(cls).append("("); + sb.append( + required.stream().map(f -> paramDecl(f, domain)).collect(Collectors.joining(", "))); + sb.append(") {\n"); + for (FieldInfo f : required) { + appendConstructorAssignment(sb, f, domain, m + " "); + } + for (FieldInfo f : optional) { + sb.append(m).append(" this.").append(f.name).append(" = Optional.empty();\n"); + } + sb.append(m).append("}\n\n"); + } + + // Package-private constructor for ConstructorCoercer deserialization (not public API). + // When there is a user-facing constructor (any record with optional fields gets one, even + // if it ends up no-arg), this one is intentionally hidden. Skipped entirely when it would + // be a no-op duplicate of that public constructor: the public ctor's params are exactly + // `required`, and this one's are `required` (+ optional, + a needsBuilder SetOverride each, + // + extensions when needsExtrasCapture) — identical only when there are no real optional + // fields to add and no extras param either, which happens for a sender-only type whose only + // reason for hasOptionals is needsExtrasSend (not receivable, so no "extensions" ctor + // param), regardless of whether it has zero or several required fields. + boolean deserCtorDuplicatesPublic = + hasOptionals && !immutable && optional.isEmpty() && !needsExtrasCapture; + if (!deserCtorDuplicatesPublic) { + String deserCtorAccess = hasOptionals ? "" : "public "; + sb.append(m).append(deserCtorAccess).append(cls).append("("); + if (fields.isEmpty() && !needsExtrasCapture) { + sb.append(") {}\n\n"); + } else { + List ctorParams = + fields.stream() + .map(f -> paramDecl(f, domain)) + .collect(Collectors.toCollection(ArrayList::new)); + if (needsBuilder) { + for (FieldInfo f : nullableOptional) { + ctorParams.add("Optional " + f.name + "SetOverride"); + } + } + if (needsExtrasCapture) { + ctorParams.add("Map extensions"); + } + sb.append(String.join(", ", ctorParams)); + sb.append(") {\n"); + for (FieldInfo f : fields) { + appendConstructorAssignment(sb, f, domain, m + " ", needsBuilder); + } + if (needsExtrasCapture) { + // Copy rather than alias: when this constructor is called from a Builder's build(), + // the argument is the Builder's own live, mutable map — a later addExtension() call + // on a reused Builder must not be able to mutate an already-built instance out from + // under it (the BiDi low-level behavioral contract requires a built/received instance + // to stay immutable). + sb.append(m).append(" this.extensions = new LinkedHashMap<>(extensions);\n"); + } + sb.append(m).append("}\n\n"); + } + } + + // Fluent setters for optional fields — sender-only types only. An immutable/receivable + // type never exposes these; use its Builder to construct one instead. + if (!immutable) { + for (FieldInfo f : optional) { + String baseType = resolveJavaType(f.typeRef, domain, true); + sb.append(m) + .append("public ") + .append(cls) + .append(" set") + .append(capitalize(f.name)) + .append("(") + .append(baseType) + .append(" ") + .append(f.name) + .append(") {\n"); + sb.append(m) + .append(" this.") + .append(f.name) + .append(" = Optional.ofNullable(") + .append(f.name) + .append(");\n"); + if (isNullable(f.typeRef)) { + sb.append(m).append(" this.").append(f.name).append("Set = true;\n"); + } + sb.append(m).append(" return this;\n"); + sb.append(m).append("}\n\n"); + } + if (needsExtrasSend) { + // An extra field may only be sent on an extensible type, and never one that shadows a + // declared field — that would leave two representations of one key. + sb.append(m) + .append("public ") + .append(cls) + .append(" addExtension(String key, Object value) {\n"); + appendExtensionCollisionCheck(sb, m + " "); + sb.append(m).append(" this.extensions.put(key, value);\n"); + sb.append(m).append(" return this;\n"); + sb.append(m).append("}\n\n"); + } + } + + // Getters + for (FieldInfo f : fields) { + String jt = fieldJavaType(f, domain); + sb.append(m) + .append("public ") + .append(jt) + .append(" get") + .append(capitalize(f.name)) + .append("() {\n"); + sb.append(m).append(" return ").append(f.name).append(";\n"); + sb.append(m).append("}\n\n"); + } + if (needsExtras) { + sb.append(m).append("public Map getExtensions() {\n"); + sb.append(m).append(" return Collections.unmodifiableMap(extensions);\n"); + sb.append(m).append("}\n\n"); + } + + // toMap() only for types sent as command params + if (needsToMap) { + boolean overrides = parentUnionRefs.stream().anyMatch(senderTypes::contains); + if (overrides) sb.append(m).append("@Override\n"); + sb.append(m).append("public Map toMap() {\n"); + if (fields.isEmpty() && !needsExtrasSend) { + sb.append(m).append(" return Collections.emptyMap();\n"); + } else { + sb.append(m).append(" Map map = new LinkedHashMap<>();\n"); + for (FieldInfo f : required) { + String serExpr = serializeExpr(f.name, f.typeRef, domain); + sb.append(m) + .append(" map.put(\"") + .append(f.wire) + .append("\", ") + .append(serExpr) + .append(");\n"); + } + for (FieldInfo f : optional) { + if (isNullable(f.typeRef)) { + // A field that was never set is omitted from the wire entirely; a field that was + // explicitly set to null must serialize as an explicit null rather than also being + // omitted, so presence (xSet) and value-nullability are checked separately. Only + // fields the schema declares nullable get this treatment — an optional field whose + // type is never null-able has no legal null wire state. + String serExpr = serializeExpr(f.name + ".get()", f.typeRef, domain); + sb.append(m).append(" if (").append(f.name).append("Set) {\n"); + sb.append(m) + .append(" map.put(\"") + .append(f.wire) + .append("\", ") + .append(f.name) + .append(".isPresent() ? ") + .append(serExpr) + .append(" : null);\n"); + sb.append(m).append(" }\n"); + } else { + String serExpr = serializeExpr("v", f.typeRef, domain); + sb.append(m) + .append(" ") + .append(f.name) + .append(".ifPresent(v -> map.put(\"") + .append(f.wire) + .append("\", ") + .append(serExpr) + .append("));\n"); + } + } + if (needsExtrasSend) { + sb.append(m).append(" extensions.forEach(map::put);\n"); + } + sb.append(m).append(" return Collections.unmodifiableMap(map);\n"); + } + sb.append(m).append("}\n\n"); + } + + if (needsBuilder) { + appendBuilder(sb, cls, fields, required, optional, domain, m, needsExtras); + } + + // A field whose spec name collides with a Java reserved word (e.g. + // script.CallFunctionParameters' + // "this", session.UserPromptHandler's "default") gets its Java identifier escaped + // (escapeReserved) but keeps its original wire key. ConstructorCoercer matches JSON + // properties to constructor parameters by exact name, so it can never find the wire key + // "this" for a parameter named "this_" — that field would silently deserialize as absent. + // fromJson reads every field by its wire key directly and calls the all-fields constructor, + // bypassing that name-matching entirely; StaticInitializerCoercer picks up a class's own + // "fromJson" ahead of ConstructorCoercer whenever one is present. A type that must preserve + // undeclared fields on receipt needs the same bypass for the same reason: ConstructorCoercer + // has no notion of "collect whatever's left over." + boolean needsFromJson = + fields.stream().anyMatch(f -> !f.name.equals(f.wire)) || needsExtrasCapture; + if (needsFromJson) { + appendFromJson( + sb, cls, fields, domain, m, needsBuilder, nullableOptional, needsExtrasCapture); + } + } + + // A caller-added extension must never shadow a declared field's wire key — that would leave + // two representations of the same key with no defined precedence. + private void appendExtensionCollisionCheck(StringBuilder sb, String bodyIndent) { + sb.append(bodyIndent).append("if (DEFINED_FIELDS.contains(key)) {\n"); + sb.append(bodyIndent) + .append( + " throw new BiDiException(\"Cannot add an extension for a declared field: \" +" + + " key);\n"); + sb.append(bodyIndent).append("}\n"); + } + + private void appendFromJson( + StringBuilder sb, + String cls, + List fields, + String domain, + String m, + boolean needsBuilder, + List nullableOptional, + boolean needsExtrasCapture) { + sb.append("\n") + .append(m) + .append("private static ") + .append(cls) + .append(" fromJson(Map map) {\n"); + sb.append(m).append(" Json json = new Json();\n"); + for (FieldInfo f : fields) { + String decodeType = + f.required ? resolveJavaType(f.typeRef, domain, true) : fieldJavaType(f, domain); + sb.append(m) + .append(" ") + .append(decodeType) + .append(" ") + .append(f.name) + .append(" = json.toType(json.toJson(map.get(\"") + .append(f.wire) + .append("\")), new TypeToken<") + .append(decodeType) + .append(">() {}.getType());\n"); + } + List ctorArgs = + fields.stream().map(f -> f.name).collect(Collectors.toCollection(ArrayList::new)); + if (needsBuilder) { + // The shared constructor's SetOverride params exist so Builder.build() can state + // explicit-null-vs-never-set for certain; fromJson has no such certainty (a missing key + // and an absent Optional look identical here — a separate, pre-existing limitation), so + // it always defers to the constructor's own Optional.isPresent()-derived fallback. + for (int i = 0; i < nullableOptional.size(); i++) { + ctorArgs.add("Optional.empty()"); + } + } + if (needsExtrasCapture) { + // Anything present on the wire that isn't one of this type's declared fields is + // preserved, not dropped, because the type is extensible. + sb.append(m).append(" Map extensions = new LinkedHashMap<>();\n"); + sb.append(m).append(" for (Map.Entry entry : map.entrySet()) {\n"); + sb.append(m).append(" if (!DEFINED_FIELDS.contains(entry.getKey())) {\n"); + sb.append(m).append(" extensions.put(entry.getKey(), entry.getValue());\n"); + sb.append(m).append(" }\n"); + sb.append(m).append(" }\n"); + ctorArgs.add("extensions"); + } + sb.append(m) + .append(" return new ") + .append(cls) + .append("(") + .append(String.join(", ", ctorArgs)) + .append(");\n"); + sb.append(m).append("}\n"); + } + + // Generates a nested public Builder for a type used both to send command params and to + // receive results/events (senderTypes ∩ receivableTypes) — the only case where a caller + // needs a mutable way to construct an instance, while a received instance itself stays fully + // immutable. All validation (required-field presence, const-value checks) lives in the outer + // class's single constructor, which build() delegates to — so a Builder-constructed instance + // is validated exactly like a deserialized one, through the same code path. + private void appendBuilder( + StringBuilder sb, + String cls, + List fields, + List required, + List optional, + String domain, + String m, + boolean needsExtras) { + sb.append(m) + .append("public static Builder builder(") + .append( + required.stream().map(f -> paramDecl(f, domain)).collect(Collectors.joining(", "))) + .append(") {\n"); + sb.append(m) + .append(" return new Builder(") + .append(required.stream().map(f -> f.name).collect(Collectors.joining(", "))) + .append(");\n"); + sb.append(m).append("}\n\n"); + + sb.append(m).append("public static final class Builder {\n\n"); + String bm = m + " "; + for (FieldInfo f : required) { + sb.append(bm) + .append("private final ") + .append(fieldJavaType(f, domain)) + .append(" ") + .append(f.name) + .append(";\n"); + } + for (FieldInfo f : optional) { + String baseType = resolveJavaType(f.typeRef, domain, true); + sb.append(bm).append("private ").append(baseType).append(" ").append(f.name).append(";\n"); + if (isNullable(f.typeRef)) { + // Tracks whether setX was ever called, independent of the value passed — build() needs + // this to tell the outer class's constructor that a null was explicit, not "never set" + // (Optional.ofNullable(null) alone is indistinguishable from an untouched field). + sb.append(bm).append("private boolean ").append(f.name).append("Set;\n"); + } + } + if (needsExtras) { + sb.append(bm) + .append("private final Map extensions = new LinkedHashMap<>();\n"); + } + sb.append("\n"); + + sb.append(bm) + .append("private Builder(") + .append( + required.stream().map(f -> paramDecl(f, domain)).collect(Collectors.joining(", "))) + .append(") {\n"); + for (FieldInfo f : required) { + sb.append(bm).append(" this.").append(f.name).append(" = ").append(f.name).append(";\n"); + } + sb.append(bm).append("}\n\n"); + + for (FieldInfo f : optional) { + String baseType = resolveJavaType(f.typeRef, domain, true); + sb.append(bm) + .append("public Builder set") + .append(capitalize(f.name)) + .append("(") + .append(baseType) + .append(" ") + .append(f.name) + .append(") {\n"); + sb.append(bm).append(" this.").append(f.name).append(" = ").append(f.name).append(";\n"); + if (isNullable(f.typeRef)) { + sb.append(bm).append(" this.").append(f.name).append("Set = true;\n"); + } + sb.append(bm).append(" return this;\n"); + sb.append(bm).append("}\n\n"); + } + + if (needsExtras) { + // An extra field may only be sent on an extensible type, and never one that shadows a + // declared field — that would leave two representations of the same wire key. + sb.append(bm).append("public Builder addExtension(String key, Object value) {\n"); + appendExtensionCollisionCheck(sb, bm + " "); + sb.append(bm).append(" this.extensions.put(key, value);\n"); + sb.append(bm).append(" return this;\n"); + sb.append(bm).append("}\n\n"); + } + + List nullableOptional = + optional.stream().filter(f -> isNullable(f.typeRef)).collect(Collectors.toList()); + sb.append(bm).append("public ").append(cls).append(" build() {\n"); + sb.append(bm).append(" return new ").append(cls).append("("); + List buildArgs = + fields.stream() + .map(f -> f.required ? f.name : "Optional.ofNullable(" + f.name + ")") + .collect(Collectors.toCollection(ArrayList::new)); + for (FieldInfo f : nullableOptional) { + buildArgs.add("Optional.of(" + f.name + "Set)"); + } + if (needsExtras) { + buildArgs.add("extensions"); + } + sb.append(String.join(", ", buildArgs)); + sb.append(");\n"); + sb.append(bm).append("}\n"); + sb.append(m).append("}\n"); + } + + /** + * Recursively appends synthetic children of {@code ownerTypeName} as nested static classes. + * {@code m} is the member-level indent (e.g. {@code " "} for a top-level class body). + */ + @SuppressWarnings("unchecked") + private void appendNestedSynthetics( + StringBuilder sb, String ownerTypeName, String domain, String m) { + List children = syntheticChildren.get(ownerTypeName); + if (children == null) return; + + for (String childName : children) { + Map childNode = types.get(childName); + if (childNode == null) continue; + String label = str(childNode, "label"); + String kind = str(childNode, "kind"); + + if ("record".equals(kind)) { + List parentRefs = reachableParents(childName); + String implementsClause = + parentRefs.isEmpty() + ? "" + : " implements " + + parentRefs.stream() + .map(r -> resolveRefToJavaClass(r, domain)) + .collect(Collectors.joining(", ")); + sb.append("\n") + .append(m) + .append("public static class ") + .append(label) + .append(implementsClause) + .append(" {\n\n"); + appendRecordBody(sb, childName, childNode, domain, m + " "); + appendNestedSynthetics(sb, childName, domain, m + " "); + sb.append(m).append("}\n"); + + } else if ("enum".equals(kind)) { + List values = (List) Objects.requireNonNull(childNode.get("values")); + sb.append("\n").append(m).append("public enum ").append(label).append(" {\n\n"); + appendEnumBody(sb, label, values, m + " "); + sb.append(m).append("}\n"); + } + } + } + + private void appendConstructorAssignment( + StringBuilder sb, FieldInfo f, String domain, String bodyIndent) { + appendConstructorAssignment(sb, f, domain, bodyIndent, false); + } + + // supportsSetOverride is true only for the all-fields constructor of a type that has a + // Builder (immutable + sendable). That constructor is called from two places: the shared + // JSON deserializer (which can never distinguish an explicit wire "null" from an absent key + // once the value has collapsed to Optional.empty() — a separate, pre-existing limitation, out + // of scope here) and Builder.build() (which knows for certain whether its setter was called, + // independent of the value passed). The extra SetOverride parameter lets build() state + // that fact explicitly; it is Optional-typed so ConstructorCoercer's reflection-based matching + // — which only requires non-Optional parameters to correspond to a real wire key — leaves it + // untouched (defaulting to empty) when the constructor is invoked from deserialization. + private void appendConstructorAssignment( + StringBuilder sb, + FieldInfo f, + String domain, + String bodyIndent, + boolean supportsSetOverride) { + if (f.required) { + boolean nullable = f.typeRef != null && Boolean.TRUE.equals(f.typeRef.get("nullable")); + appendConstValidation(sb, f, nullable, bodyIndent); + if (isPrimitive(f.typeRef) || nullable) { + sb.append(bodyIndent) + .append("this.") + .append(f.name) + .append(" = ") + .append(f.name) + .append(";\n"); + } else { + sb.append(bodyIndent) + .append("this.") + .append(f.name) + .append(" = Objects.requireNonNull(") + .append(f.name) + .append(", \"") + .append(f.wire) + .append(" is required\");\n"); + } + } else { + sb.append(bodyIndent) + .append("this.") + .append(f.name) + .append(" = ") + .append(f.name) + .append(" != null ? ") + .append(f.name) + .append(" : Optional.empty();\n"); + if (isNullable(f.typeRef)) { + sb.append(bodyIndent).append("this.").append(f.name).append("Set = "); + if (supportsSetOverride) { + sb.append(f.name) + .append("SetOverride != null && ") + .append(f.name) + .append("SetOverride.isPresent() ? ") + .append(f.name) + .append("SetOverride.get() : this.") + .append(f.name) + .append(".isPresent();\n"); + } else { + sb.append("this.").append(f.name).append(".isPresent();\n"); + } + } + } + } + + // A const field's value is fixed by the spec; a caller-supplied (or, on deserialization, + // remote-supplied) value that isn't the literal — or null, when the const is also nullable — + // must be rejected locally rather than silently accepted. This is what lets a nullable const + // (browsingContext.SetBypassCSPParameters.bypass, + // emulation.SetScriptingEnabledParameters.enabled) + // actually behave as "the literal or null" instead of any value of that type going unchecked. + private void appendConstValidation( + StringBuilder sb, FieldInfo f, boolean nullable, String bodyIndent) { + if (f.typeRef == null || !f.typeRef.containsKey("const")) return; + Object constValue = f.typeRef.get("const"); + String literal = + constValue instanceof Boolean ? constValue.toString() : "\"" + constValue + "\""; + String condition = + nullable + ? f.name + " != null && !Objects.equals(" + f.name + ", " + literal + ")" + : "!Objects.equals(" + f.name + ", " + literal + ")"; + String suffix = nullable ? " or null, got: " : ", got: "; + sb.append(bodyIndent).append("if (").append(condition).append(") {\n"); + sb.append(bodyIndent) + .append( + String.format( + " throw new BiDiException(\"%s must be \" + %s + \"%s\" + %s);%n", + f.wire, literal, suffix, f.name)); + sb.append(bodyIndent).append("}\n"); + } + + // ─── Enum ───────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private void generateEnum(String typeName, Map node, Path outDir) + throws IOException { + + String domain = domainOf(typeName); + String pkg = domainPackage(domain); + String cls = simpleNameOf(typeName); + List values = (List) Objects.requireNonNull(node.get("values")); + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE); + sb.append("package ").append(pkg).append(";\n\n"); + sb.append("import org.openqa.selenium.Beta;\n"); + sb.append("import org.openqa.selenium.bidi.BiDiException;\n\n"); + sb.append(API_JAVADOC); + sb.append("@Beta\n"); + sb.append("public enum ").append(cls).append(" {\n\n"); + appendEnumBody(sb, cls, values, " "); + sb.append("}\n"); + + writeFile(outDir, pkg.replace('.', '/') + "/" + cls + ".java", sb.toString()); + } + + private void appendEnumBody(StringBuilder sb, String cls, List values, String m) { + for (int i = 0; i < values.size(); i++) { + String v = values.get(i); + sb.append(m) + .append(toEnumConstant(v)) + .append("(\"") + .append(v) + .append("\")") + .append(i < values.size() - 1 ? "," : ";") + .append("\n"); + } + sb.append("\n").append(m).append("private final String value;\n\n"); + sb.append(m).append(cls).append("(String value) {\n"); + sb.append(m).append(" this.value = value;\n"); + sb.append(m).append("}\n\n"); + sb.append(m).append("public static ").append(cls).append(" fromString(String s) {\n"); + sb.append(m).append(" for (").append(cls).append(" e : values()) {\n"); + sb.append(m).append(" if (e.value.equalsIgnoreCase(s)) return e;\n"); + sb.append(m).append(" }\n"); + sb.append(m) + .append(" throw new BiDiException(\"Unknown ") + .append(cls) + .append(" value: \" + s);\n"); + sb.append(m).append("}\n\n"); + sb.append(m).append("@Override\n"); + sb.append(m).append("public String toString() {\n"); + sb.append(m).append(" return value;\n"); + sb.append(m).append("}\n"); + } + + // ─── Union ──────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private void generateUnion(String typeName, Map node, Path outDir) + throws IOException { + + String domain = domainOf(typeName); + String pkg = domainPackage(domain); + String cls = simpleNameOf(typeName); + List variants = (List) Objects.requireNonNull(node.get("variants")); + Map selector = mapField(node, "selector"); + + StringBuilder sb = new StringBuilder(); + sb.append(LICENSE); + sb.append("package ").append(pkg).append(";\n\n"); + // Always import all utilities that inner static classes (nested synthetics) may use. + sb.append("import java.util.Collections;\n"); + sb.append("import java.util.LinkedHashMap;\n"); + sb.append("import java.util.Map;\n"); + sb.append("import java.util.Objects;\n"); + sb.append("import java.util.Optional;\n"); + sb.append("import org.jspecify.annotations.Nullable;\n"); + sb.append("import org.openqa.selenium.Beta;\n"); + sb.append("import org.openqa.selenium.bidi.BiDiException;\n"); + sb.append("import org.openqa.selenium.bidi.ConverterFunctions;\n\n"); + sb.append(API_JAVADOC); + sb.append("@Beta\n"); + // Unions are interfaces, not abstract classes: a union can itself be a variant of more + // than one other union (e.g. PrimitiveProtocolValue is a member of both RemoteValue and + // LocalValue), which only interface-to-interface "extends" can express — a class can only + // ever have one superclass. + List parentUnionRefs = reachableParents(typeName); + String extendsClause = + parentUnionRefs.isEmpty() + ? "" + : " extends " + + parentUnionRefs.stream() + .map(r -> resolveRefToJavaClass(r, domain)) + .collect(Collectors.joining(", ")); + sb.append("public interface ").append(cls).append(extendsClause).append(" {\n\n"); + if (senderTypes.contains(typeName)) { + sb.append(" Map toMap();\n\n"); + } + + sb.append(" @SuppressWarnings(\"unchecked\")\n"); + sb.append(" public static ").append(cls).append(" fromMap(Map map) {\n"); + + if (selector != null && selector.containsKey("by")) { + // Discriminated union — dispatch on a shared field value + String byField = str(selector, "by"); + List> selectorVariants = + (List>) selector.get("variants"); + String defaultRef = str(selector, "default"); + + sb.append(" Object discriminator = map.get(\"").append(byField).append("\");\n"); + + if (selectorVariants != null) { + for (Map sv : selectorVariants) { + Object value = sv.get("value"); + String variantRef = str(sv, "ref"); + String variantClass = resolveRefToJavaClass(variantRef, domain); + // A discriminator value isn't always a string (e.g. + // bluetooth.HandleRequestDevicePromptParameters dispatches on a boolean "accept" + // field) — the deserialized map value is a real Boolean there, so comparing it + // against a quoted string literal via String#equals would never match. Emit a literal + // of the value's own type instead of always quoting it. A numeric discriminator is + // the same story: Selenium's JSON parser (see JsonInput#nextNumber, also used to read + // this very schema) materializes an integer wire value as Long and a decimal one as + // Double — never String — so it needs a real numeric literal (with the matching L/d + // suffix) rather than a quoted comparison too. + String test; + if (value == null) { + test = "discriminator == null"; + } else if (value instanceof Boolean) { + test = "Objects.equals(discriminator, " + value + ")"; + } else if (value instanceof Long) { + test = "Objects.equals(discriminator, " + value + "L)"; + } else if (value instanceof Double) { + test = "Objects.equals(discriminator, " + value + "d)"; + } else { + test = "\"" + value + "\".equals(discriminator)"; + } + sb.append(" if (").append(test).append(")\n"); + appendFromMapReturn(sb, cls, variantRef, variantClass, domain, " "); + } + } + if (defaultRef != null) { + String defaultClass = resolveRefToJavaClass(defaultRef, domain); + appendFromMapReturn(sb, cls, defaultRef, defaultClass, domain, " "); + } else { + sb.append(" throw new BiDiException(\"Unknown ") + .append(cls) + .append(" discriminator '\" + discriminator + \"'\");\n"); + } + + } else if (selector != null && selector.containsKey("ordered")) { + // Structural union — dispatch on required field presence (schema-specified order) + List> ordered = (List>) selector.get("ordered"); + for (Map arm : ordered) { + String armRef = str(arm, "ref"); + List requires = (List) arm.get("requires"); + String armClass = resolveRefToJavaClass(armRef, domain); + if (requires != null && !requires.isEmpty()) { + String condition = + requires.stream() + .map(k -> "map.containsKey(\"" + k + "\")") + .collect(Collectors.joining(" && ")); + sb.append(" if (").append(condition).append(")\n"); + appendFromMapReturn(sb, cls, armRef, armClass, domain, " "); + } + } + sb.append(" throw new BiDiException(\"Cannot determine ") + .append(cls) + .append(" variant from fields: \" + map.keySet());\n"); + + } else { + sb.append(" throw new BiDiException(\"Cannot deserialize ").append(cls).append("\");\n"); + } + + sb.append(" }\n\n"); + + // The shared JSON deserializer (org.openqa.selenium.json.StaticInitializerCoercer) only + // recognizes a static factory method named exactly "fromJson", not "fromMap" — without + // this, a union type nested as a field inside another generated class (e.g. Initiator + // inside network.BeforeRequestSentParameters) falls through to a generic reflection-based + // coercer that tries to call the implicit no-arg constructor every class gets, including + // this abstract one, and always throws InstantiationException. fromMap stays the public, + // toMap-symmetric API; this just gives the shared infra a hook it can actually find. + sb.append(" private static ").append(cls).append(" fromJson(Map map) {\n"); + sb.append(" return fromMap(map);\n"); + sb.append(" }\n"); + + // Synthetic variant records/enums as nested static classes + appendNestedSynthetics(sb, typeName, domain, " "); + + sb.append("}\n"); + writeFile(outDir, pkg.replace('.', '/') + "/" + cls + ".java", sb.toString()); + } + + // Emits a `return ConverterFunctions.fromMap(...).apply(map);` line inside a union's fromMap(). + // Always casts through Object: some dispatch targets (multi-parent variants or indirect + // subtypes) do not statically extend the union class, but are valid protocol representations. + // The @SuppressWarnings("unchecked") on the enclosing fromMap() covers the cast. + private void appendFromMapReturn( + StringBuilder sb, + String unionCls, + String variantRef, + String variantClass, + String domain, + String indent) { + sb.append(indent) + .append("return (") + .append(unionCls) + .append(")(Object) ConverterFunctions.fromMap(") + .append(variantClass) + .append(".class).apply(map);\n"); + } + + // ═══════════════════════════════════════════════════════════════ + // Type resolution + // ═══════════════════════════════════════════════════════════════ + + private String resolveJavaType(Map typeRef, String contextDomain, boolean box) { + if (typeRef == null) return "Object"; + if (typeRef.containsKey("primitive")) { + return primitiveToJava(str(typeRef, "primitive"), box); + } + if (typeRef.containsKey("const")) { + // A const's Java type must reflect its actual wire type (e.g. the boolean literal in + // browsingContext.SetBypassCSPParameters.bypass), not always String — most consts are + // fixed discriminator strings, but a nullable const can be a caller-settable boolean. + Object constValue = typeRef.get("const"); + if (constValue instanceof Boolean) { + return primitiveToJava("boolean", box); + } + return "String"; + } + if (typeRef.containsKey("ref")) { + return resolveRefType(str(typeRef, "ref"), contextDomain, box); + } + if (typeRef.containsKey("list")) { + @SuppressWarnings("unchecked") + Map elem = (Map) typeRef.get("list"); + return "java.util.List<" + resolveJavaType(elem, contextDomain, true) + ">"; + } + if (typeRef.containsKey("map")) { + @SuppressWarnings("unchecked") + Map val = (Map) typeRef.get("map"); + return "java.util.Map"; + } + return "Object"; // inline union or unhandled shape + } + + private String resolveRefType(String ref, String contextDomain, boolean box) { + Map node = types.get(ref); + if (node == null) return resolveRefToJavaClass(ref, contextDomain); + String kind = str(node, "kind"); + if ("alias".equals(kind)) { + @SuppressWarnings("unchecked") + Map aliasType = (Map) node.get("type"); + return aliasType != null ? resolveJavaType(aliasType, contextDomain, box) : "Object"; + } + return resolveRefToJavaClass(ref, contextDomain); + } + + private String resolveRefToJavaClass(String ref, String contextDomain) { + Map node = types.get(ref); + if (node != null && Boolean.TRUE.equals(node.get("synthetic"))) { + // Synthetic types are nested inside their owner — e.g. DownloadEndParams.CanceledParams + String ownerRef = str(node, "owner"); + String label = str(node, "label"); + if (ownerRef != null && label != null) { + return resolveRefToJavaClass(ownerRef, contextDomain) + "." + label; + } + } + String refDomain = domainOf(ref); + String simpleName = simpleNameOf(ref); + if (refDomain.equals(contextDomain)) { + return simpleName; + } + return domainPackage(refDomain) + "." + simpleName; + } + + private String fieldJavaType(FieldInfo f, String domain) { + if (!f.required) { + return "Optional<" + resolveJavaType(f.typeRef, domain, true) + ">"; + } + // A required field that is also nullable (schema T / null) must be boxed even though it's + // required: null has no representation in a primitive, so a required+nullable primitive + // field (e.g. a nullable long) has to become its wrapper type (Long) or callers could never + // actually pass null despite @Nullable saying they can, and ConstructorCoercer would crash + // trying to pass null to a primitive constructor parameter during deserialization. + return resolveJavaType(f.typeRef, domain, isNullable(f.typeRef)); + } + + // A required field whose value may still legitimately be null (schema T / null) needs + // @Nullable on its constructor parameter: ConstructorCoercer's null-value check + // (org.openqa.selenium.json.ConstructorCoercer#isNullable) reads it off the parameter's + // annotated type to allow a present key with a null value through, distinct from the + // key-presence check that "required" already governs. + private String paramDecl(FieldInfo f, String domain) { + String jt = fieldJavaType(f, domain); + return f.required && isNullable(f.typeRef) + ? annotateNullable(jt) + " " + f.name + : jt + " " + f.name; + } + + // @Nullable is TYPE_USE-only, so on a qualified/generic type it must sit directly before the + // simple name (java.util.@Nullable List), not before the whole reference — javac + // rejects the latter placement. + private String annotateNullable(String javaType) { + int generic = javaType.indexOf('<'); + String head = generic >= 0 ? javaType.substring(0, generic) : javaType; + String tail = generic >= 0 ? javaType.substring(generic) : ""; + int dot = head.lastIndexOf('.'); + if (dot >= 0) { + return head.substring(0, dot + 1) + "@Nullable " + head.substring(dot + 1) + tail; + } + return "@Nullable " + head + tail; + } + + /** True when the type ref maps to a Java value type (long, boolean) that cannot be null. */ + private boolean isPrimitive(Map typeRef) { + if (typeRef == null) return false; + String prim = str(typeRef, "primitive"); + if ("boolean".equals(prim) || "integer".equals(prim)) return true; + // A non-nullable boolean const (e.g. bluetooth.HandleRequestDevicePromptParameters.accept) + // resolves to the unboxed "boolean" Java type, same as a plain boolean primitive field — + // it must be treated the same way here so the constructor doesn't null-check a value that + // can never be null. + if (typeRef.get("const") instanceof Boolean) return true; + // Follow aliases (e.g. js-uint → { primitive: "integer" }) + String ref = str(typeRef, "ref"); + if (ref != null) { + Map node = types.get(ref); + if (node != null && "alias".equals(str(node, "kind"))) { + @SuppressWarnings("unchecked") + Map inner = (Map) node.get("type"); + return isPrimitive(inner); + } + } + return false; + } + + /** True when the schema declares this type ref's value as nullable ({@code T / null}). */ + private boolean isNullable(Map typeRef) { + if (typeRef == null) return false; + if (Boolean.TRUE.equals(typeRef.get("nullable"))) return true; + // Follow aliases, same as isPrimitive. + String ref = str(typeRef, "ref"); + if (ref != null) { + Map node = types.get(ref); + if (node != null && "alias".equals(str(node, "kind"))) { + @SuppressWarnings("unchecked") + Map inner = (Map) node.get("type"); + return isNullable(inner); + } + } + return false; + } + + private String serializeExpr(String varName, Map 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 aliasNode = types.get(str(typeRef, "ref")); + if (aliasNode != null && "alias".equals(str(aliasNode, "kind"))) { + @SuppressWarnings("unchecked") + Map inner = (Map) aliasNode.get("type"); + return serializeExpr(varName, inner, domain); + } + return varName; + } + if (typeRef.containsKey("list")) { + @SuppressWarnings("unchecked") + Map elem = (Map) 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; + } + if (typeRef.containsKey("map")) { + // resolveJavaType already resolves a "map" typeRef to java.util.Map (see + // above) — without this branch, a Map field would + // be put on the wire as raw Java objects instead of their wire-compatible shape. + @SuppressWarnings("unchecked") + Map val = (Map) typeRef.get("map"); + String valKind = resolvedKindFromTypeRef(val); + if ("enum".equals(valKind)) { + return varName + + ".entrySet().stream().collect(java.util.stream.Collectors.toMap(" + + "java.util.Map.Entry::getKey, e -> e.getValue().toString(), (a, b) -> b, " + + "java.util.LinkedHashMap::new))"; + } + if ("record".equals(valKind) || "union".equals(valKind)) { + return varName + + ".entrySet().stream().collect(java.util.stream.Collectors.toMap(" + + "java.util.Map.Entry::getKey, e -> e.getValue().toMap(), (a, b) -> b, " + + "java.util.LinkedHashMap::new))"; + } + return varName; + } + return varName; + } + + private String resolvedKindOf(String ref) { + Map node = types.get(ref); + if (node == null) return "unknown"; + String kind = str(node, "kind"); + if ("alias".equals(kind)) { + @SuppressWarnings("unchecked") + Map inner = (Map) node.get("type"); + return inner != null ? resolvedKindFromTypeRef(inner) : "unknown"; + } + return kind; + } + + private String resolvedKindFromTypeRef(Map typeRef) { + if (typeRef == null) return "unknown"; + if (typeRef.containsKey("ref")) return resolvedKindOf(str(typeRef, "ref")); + if (typeRef.containsKey("primitive") || typeRef.containsKey("const")) return "primitive"; + if (typeRef.containsKey("list")) return "list"; + if (typeRef.containsKey("map")) return "map"; + return "unknown"; + } + + private String resolveEventMapper(Map paramsRef, String contextDomain) { + String javaType = resolveJavaType(paramsRef, contextDomain, true); + if (paramsRef.containsKey("ref")) { + String resolvedKind = resolvedKindOf(str(paramsRef, "ref")); + if ("union".equals(resolvedKind)) { + return javaType + "::fromMap"; + } + } + return "ConverterFunctions.fromMap(" + javaType + ".class)"; + } + + private String resolveCommandResultArg(Map resultRef, String contextDomain) { + if (resultRef == null) return null; + if (resultRef.containsKey("list") || resultRef.containsKey("map")) { + // A raw Class token (e.g. List.class) erases the element/value type at runtime, so the + // shared JSON coercer (which resolves List/Map generically off a real + // java.lang.reflect.Type — see CollectionCoercer/MapCoercer) would have no way to know + // what to coerce each element/value into. TypeToken captures that generic signature as an + // actual Type, matching the same resolveJavaType string already used for this method's + // declared return type, so the two can never drift out of sync. + String containerType = resolveJavaType(resultRef, contextDomain, true); + return "new org.openqa.selenium.json.TypeToken<" + containerType + ">() {}.getType()"; + } + String javaType = resolveJavaType(resultRef, contextDomain, true); + if (resultRef.containsKey("ref")) { + String resolvedKind = resolvedKindOf(str(resultRef, "ref")); + if ("union".equals(resolvedKind)) { + return "input -> {\n" + + " @SuppressWarnings(\"unchecked\")\n" + + " java.util.Map m =" + + " input.readNonNull(java.util.Map.class);\n" + + " return " + + javaType + + ".fromMap(m);\n" + + " }"; + } + } + if (resultRef.containsKey("primitive") || resultRef.containsKey("const")) { + String prim = str(resultRef, "primitive"); + if ("boolean".equals(prim)) return "Boolean.class"; + if ("integer".equals(prim)) return "Long.class"; + if ("number".equals(prim)) return "Number.class"; + return "String.class"; + } + return javaType + ".class"; + } + + // ═══════════════════════════════════════════════════════════════ + // Parsing helpers + // ═══════════════════════════════════════════════════════════════ + + private FieldInfo parseField(Map 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 type = (Map) raw.get("type"); + // wire key stays as the original spec name for JSON serialization + return new FieldInfo(name, wire != null ? wire : rawName, required, type); + } + + private static Map>> groupByDomain( + List> entries) { + Map>> result = new LinkedHashMap<>(); + for (Map e : entries) { + String domain = (String) e.get("domain"); + result.computeIfAbsent(domain, k -> new ArrayList<>()).add(e); + } + return result; + } + + @SuppressWarnings("unchecked") + private static Map mapField(Map parent, String key) { + Object v = parent == null ? null : parent.get(key); + return v instanceof Map ? (Map) v : null; + } + + private static String str(Map map, String key) { + Object v = map == null ? null : map.get(key); + return v instanceof String ? (String) v : null; + } + } + + // ═══════════════════════════════════════════════════════════════ + // Static utilities (package-private for tests) + // ═══════════════════════════════════════════════════════════════ + + static String escapeReserved(String name) { + return JAVA_RESERVED.contains(name) ? name + "_" : name; + } + + static String domainPackage(String domain) { + return BASE_PKG + ".protocol." + domain.toLowerCase(Locale.ROOT); + } + + static String domainOf(String typeName) { + int dot = typeName.indexOf('.'); + return dot >= 0 ? typeName.substring(0, dot) : typeName; + } + + static String simpleNameOf(String typeName) { + int dot = typeName.indexOf('.'); + return dot >= 0 ? typeName.substring(dot + 1) : typeName; + } + + static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + static String toConstantName(String camel) { + // Insert underscore before each uppercase letter, then upper-case the whole string. + // Locale.ROOT avoids locale-sensitive case conversion (e.g. Turkish "i" -> "İ") producing a + // non-ASCII constant name depending on the JVM's default locale. + return camel.replaceAll("([A-Z])", "_$1").toUpperCase(Locale.ROOT); + } + + static String toEnumConstant(String wireValue) { + return wireValue.toUpperCase(Locale.ROOT).replace('-', '_').replace('.', '_').replace(' ', '_'); + } + + static String primitiveToJava(String primitive, boolean box) { + if (primitive == null) return "Object"; + switch (primitive) { + case "string": + return "String"; + case "integer": + return box ? "Long" : "long"; + case "number": + return "Number"; + case "boolean": + return box ? "Boolean" : "boolean"; + case "any": + return "Object"; + case "null": + return "Void"; + default: + return "Object"; + } + } + + // ─── File I/O ───────────────────────────────────────────────── + + private static void writeFile(Path tempDir, String relativePath, String content) + throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.write(file, content.getBytes(UTF_8)); + } + + // Files.walkFileTree does not guarantee a stable traversal order, and an unset JarEntry + // timestamp defaults to the moment it's written — either would make bidi-generated.srcjar + // differ byte-for-byte between builds of the exact same schema, which breaks Bazel's + // action-cache reuse for everything downstream. Sorting entries by their normalized jar path + // and zeroing every entry's timestamp makes the output a pure function of the generated + // content. + private static void packToJar(Path tempDir, Path outputJar) throws IOException { + Files.createDirectories(outputJar.getParent()); + List paths; + try (Stream walk = Files.walk(tempDir)) { + paths = + walk.filter(p -> !p.equals(tempDir)) + .sorted(Comparator.comparing(p -> relativeJarPath(tempDir, p))) + .collect(Collectors.toList()); + } + try (OutputStream os = Files.newOutputStream(outputJar); + JarOutputStream jos = new JarOutputStream(os)) { + for (Path path : paths) { + boolean isDirectory = Files.isDirectory(path); + String rel = relativeJarPath(tempDir, path); + JarEntry entry = new JarEntry(isDirectory ? rel + "/" : rel); + entry.setTime(0L); + jos.putNextEntry(entry); + if (!isDirectory) { + try (InputStream is = Files.newInputStream(path)) { + is.transferTo(jos); + } + } + jos.closeEntry(); + } + } + } + + private static String relativeJarPath(Path root, Path path) { + return root.relativize(path).toString().replace('\\', '/'); + } + + private static void deleteRecursive(Path dir) throws IOException { + Files.walkFileTree( + dir, + new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException { + if (e != null) throw e; + Files.delete(d); + return FileVisitResult.CONTINUE; + } + }); + } + + // ─── Inner data class ───────────────────────────────────────── + + private static class FieldInfo { + final String name; + final String wire; + final boolean required; + final Map typeRef; + + FieldInfo(String name, String wire, boolean required, Map typeRef) { + this.name = name; + this.wire = wire; + this.required = required; + this.typeRef = typeRef; + } + } +} diff --git a/java/src/org/openqa/selenium/bidi/ConverterFunctions.java b/java/src/org/openqa/selenium/bidi/ConverterFunctions.java index 5ce68cca4d1ce..9eeb995376d1a 100644 --- a/java/src/org/openqa/selenium/bidi/ConverterFunctions.java +++ b/java/src/org/openqa/selenium/bidi/ConverterFunctions.java @@ -17,20 +17,44 @@ package org.openqa.selenium.bidi; +import java.io.StringReader; import java.lang.reflect.Type; +import java.util.Map; import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.openqa.selenium.Beta; import org.openqa.selenium.internal.Require; +import org.openqa.selenium.json.Json; import org.openqa.selenium.json.JsonInput; @Beta public class ConverterFunctions { + private static final Json JSON = new Json(); + private ConverterFunctions() { throw new IllegalStateException("Utility class"); } + /** + * Returns a function that deserializes a {@code Map} event payload into an + * instance of {@code type} via the Selenium JSON library (ConstructorCoercer). + * + * @param type the class to deserialize the map into + * @param the deserialized type + * @return a function that converts a raw event payload into an instance of {@code type} + */ + public static Function, T> fromMap(Class type) { + Require.nonNull("Type", type); + return map -> { + String json = JSON.toJson(map); + try (StringReader reader = new StringReader(json); + JsonInput input = JSON.newInput(reader)) { + return input.readNonNull(type); + } + }; + } + public static Function map(final String keyName, Type typeOfX) { Require.nonNull("Key name", keyName); Require.nonNull("Type to convert to", typeOfX); diff --git a/java/test/org/openqa/selenium/bidi/BUILD.bazel b/java/test/org/openqa/selenium/bidi/BUILD.bazel index 902f5eed785b2..4e6c413218e91 100644 --- a/java/test/org/openqa/selenium/bidi/BUILD.bazel +++ b/java/test/org/openqa/selenium/bidi/BUILD.bazel @@ -11,6 +11,7 @@ java_selenium_test_suite( ], deps = [ "//java/src/org/openqa/selenium/bidi", + "//java/src/org/openqa/selenium/bidi:bidi-generated", "//java/src/org/openqa/selenium/bidi/browsingcontext", "//java/src/org/openqa/selenium/bidi/log", "//java/src/org/openqa/selenium/bidi/module", diff --git a/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/BUILD.bazel new file mode 100644 index 0000000000000..6352d9b3d36c5 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "SmallTests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/json", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/InfoTest.java b/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/InfoTest.java new file mode 100644 index 0000000000000..233c07c7e4ecb --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/InfoTest.java @@ -0,0 +1,94 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.browsingcontext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.json.Json; +import org.openqa.selenium.json.JsonException; + +@Tag("UnitTests") +class InfoTest { + + private static final String BASE = + "\"clientWindow\": \"w1\", \"context\": \"ctx1\", \"url\": \"https://example.com\"," + + " \"userContext\": \"default\""; + + @Test + void requiredNullableFieldsAcceptAnExplicitNullValue() { + String raw = "{" + BASE + ", \"children\": null, \"originalOpener\": null}"; + + Info info = new Json().toType(raw, Info.class); + + assertThat(info.getChildren()).isNull(); + assertThat(info.getOriginalOpener()).isNull(); + } + + @Test + void requiredNullableFieldsMustStillBePresentAsAKey() { + // "originalOpener" is required + nullable — the value may be null, but the key may not be + // missing entirely. Omitting it should still fail, exactly like any other required field. + String raw = "{" + BASE + ", \"children\": null}"; + + assertThatExceptionOfType(JsonException.class) + .isThrownBy(() -> new Json().toType(raw, Info.class)); + } + + @Test + void optionalNullableFieldDefaultsToEmptyWhenNeverSent() { + String raw = "{" + BASE + ", \"children\": null, \"originalOpener\": null}"; + + Info info = new Json().toType(raw, Info.class); + + assertThat(info.getParent()).isEmpty(); + } + + @Test + void optionalNullableFieldAcceptsAnExplicitNullTheSameAsAbsence() { + // Unlike the outbound xSet tracking used for toMap(), inbound deserialization has no way (and + // no need) to distinguish "the browser sent an explicit null" from "the key was absent" for + // an optional field — both collapse to Optional.empty(). Info is receiver-only (no toMap()), + // so this asymmetry never needs to round-trip. + String raw = "{" + BASE + ", \"children\": null, \"originalOpener\": null, \"parent\": null}"; + + Info info = new Json().toType(raw, Info.class); + + assertThat(info.getParent()).isEmpty(); + } + + @Test + void allFieldsPopulatedDeserializeCorrectly() { + String raw = + "{" + + BASE + + ", \"children\": [], \"originalOpener\": \"opener-ctx\", \"parent\": \"parent-ctx\"}"; + + Info info = new Json().toType(raw, Info.class); + + assertThat(info.getChildren()).isEmpty(); + assertThat(info.getOriginalOpener()).isEqualTo("opener-ctx"); + assertThat(info.getParent()).contains("parent-ctx"); + assertThat(info.getClientWindow()).isEqualTo("w1"); + assertThat(info.getContext()).isEqualTo("ctx1"); + assertThat(info.getUrl()).isEqualTo("https://example.com"); + assertThat(info.getUserContext()).isEqualTo("default"); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/SetViewportParametersTest.java b/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/SetViewportParametersTest.java new file mode 100644 index 0000000000000..6e4c58b7d2e64 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/browsingcontext/SetViewportParametersTest.java @@ -0,0 +1,84 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.browsingcontext; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("UnitTests") +class SetViewportParametersTest { + + @Test + void noArgConstructorProducesAnEmptyWirePayload() { + Map map = new SetViewportParameters().toMap(); + + assertThat(map).isEmpty(); + } + + @Test + void untouchedFieldsAreOmittedFromTheWire() { + Map map = new SetViewportParameters().setContext("ctx-1").toMap(); + + assertThat(map).containsEntry("context", "ctx-1"); + assertThat(map).doesNotContainKey("viewport"); + assertThat(map).doesNotContainKey("devicePixelRatio"); + assertThat(map).doesNotContainKey("userContexts"); + } + + @Test + void settingARealValueOnANullableFieldSendsIt() { + Map map = + new SetViewportParameters().setViewport(new Viewport(800, 600)).toMap(); + + assertThat(map).containsKey("viewport"); + assertThat(map.get("viewport")).isEqualTo(Map.of("width", 800L, "height", 600L)); + } + + @Test + void explicitlyClearingANullableFieldSendsAnExplicitNull() { + Map map = new SetViewportParameters().setViewport(null).toMap(); + + assertThat(map).containsKey("viewport"); + assertThat(map.get("viewport")).isNull(); + } + + @Test + void explicitlyClearingANonNullableOptionalFieldIsIndistinguishableFromNeverSettingIt() { + // context's schema type never declares "/ null", so there is no explicit-null wire state to + // represent for it — passing null just clears back to "unset", same as never calling the + // setter at all. + Map map = new SetViewportParameters().setContext(null).toMap(); + + assertThat(map).doesNotContainKey("context"); + } + + @Test + void devicePixelRatioFollowsTheSameNullableRulesAsViewport() { + Map untouched = new SetViewportParameters().toMap(); + Map cleared = new SetViewportParameters().setDevicePixelRatio(null).toMap(); + Map set = new SetViewportParameters().setDevicePixelRatio(2.0).toMap(); + + assertThat(untouched).doesNotContainKey("devicePixelRatio"); + assertThat(cleared).containsKey("devicePixelRatio"); + assertThat(cleared.get("devicePixelRatio")).isNull(); + assertThat(set).containsEntry("devicePixelRatio", 2.0); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/emulation/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/emulation/BUILD.bazel new file mode 100644 index 0000000000000..6352d9b3d36c5 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/emulation/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "SmallTests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/json", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/emulation/SetTimezoneOverrideParametersTest.java b/java/test/org/openqa/selenium/bidi/protocol/emulation/SetTimezoneOverrideParametersTest.java new file mode 100644 index 0000000000000..b2aa48e445259 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/emulation/SetTimezoneOverrideParametersTest.java @@ -0,0 +1,50 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.emulation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("UnitTests") +class SetTimezoneOverrideParametersTest { + + @Test + void requiredNullableFieldAcceptsNullAtConstructionTime() { + SetTimezoneOverrideParameters params = new SetTimezoneOverrideParameters(null); + + assertThat(params.getTimezone()).isNull(); + } + + @Test + void nullValueIsSentAsAnExplicitKeyNotOmitted() { + Map map = new SetTimezoneOverrideParameters(null).toMap(); + + assertThat(map).containsKey("timezone"); + assertThat(map.get("timezone")).isNull(); + } + + @Test + void realValueIsSentNormally() { + Map map = new SetTimezoneOverrideParameters("America/New_York").toMap(); + + assertThat(map).containsEntry("timezone", "America/New_York"); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/emulation/SetTouchOverrideParametersTest.java b/java/test/org/openqa/selenium/bidi/protocol/emulation/SetTouchOverrideParametersTest.java new file mode 100644 index 0000000000000..c465da84638f6 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/emulation/SetTouchOverrideParametersTest.java @@ -0,0 +1,62 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.emulation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.json.Json; + +@Tag("UnitTests") +class SetTouchOverrideParametersTest { + + @Test + void requiredNullablePrimitiveFieldAcceptsNullAtConstructionTime() { + SetTouchOverrideParameters params = new SetTouchOverrideParameters(null); + + assertThat(params.getMaxTouchPoints()).isNull(); + } + + @Test + void nullValueIsSentAsAnExplicitKeyNotOmitted() { + Map map = new SetTouchOverrideParameters(null).toMap(); + + assertThat(map).containsKey("maxTouchPoints"); + assertThat(map.get("maxTouchPoints")).isNull(); + } + + @Test + void deserializingAnExplicitNullDoesNotThrow() { + String raw = "{\"maxTouchPoints\": null}"; + + SetTouchOverrideParameters params = new Json().toType(raw, SetTouchOverrideParameters.class); + + assertThat(params.getMaxTouchPoints()).isNull(); + } + + @Test + void deserializingARealValueWorks() { + String raw = "{\"maxTouchPoints\": 5}"; + + SetTouchOverrideParameters params = new Json().toType(raw, SetTouchOverrideParameters.class); + + assertThat(params.getMaxTouchPoints()).isEqualTo(5L); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/log/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/log/BUILD.bazel new file mode 100644 index 0000000000000..6352d9b3d36c5 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/log/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "SmallTests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/json", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/log/EntryTest.java b/java/test/org/openqa/selenium/bidi/protocol/log/EntryTest.java new file mode 100644 index 0000000000000..b9b0435b7925f --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/log/EntryTest.java @@ -0,0 +1,47 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.log; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.json.Json; + +@Tag("UnitTests") +class EntryTest { + + @Test + void consoleLogEntryWithAStringArgDeserializes() { + String raw = + "{\"type\":\"console\",\"level\":\"info\",\"method\":\"log\"," + + "\"source\":{\"realm\":\"r1\",\"context\":\"c1\"}," + + "\"text\":\"Hello, world!\",\"timestamp\":1," + + "\"args\":[{\"type\":\"string\",\"value\":\"Hello, world!\"}]}"; + + @SuppressWarnings("unchecked") + Map map = (Map) new Json().toType(raw, Json.MAP_TYPE); + + Entry entry = Entry.fromMap(map); + + assertThat(entry).isInstanceOf(ConsoleLogEntry.class); + ConsoleLogEntry consoleLogEntry = (ConsoleLogEntry) entry; + assertThat(consoleLogEntry.getArgs()).hasSize(1); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/module/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/module/BUILD.bazel new file mode 100644 index 0000000000000..d6ad15a6fc542 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/module/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "BIDI_BROWSERS", "JUNIT5_DEPS", "java_selenium_test_suite") + +java_selenium_test_suite( + name = "large-tests", + size = "large", + srcs = glob(["*Test.java"]), + browsers = BIDI_BROWSERS, + tags = [ + "selenium-remote", + ], + deps = [ + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/firefox", + "//java/src/org/openqa/selenium/grid/security", + "//java/src/org/openqa/selenium/json", + "//java/src/org/openqa/selenium/remote", + "//java/src/org/openqa/selenium/support", + "//java/test/org/openqa/selenium/environment", + "//java/test/org/openqa/selenium/testing:annotations", + "//java/test/org/openqa/selenium/testing:test-base", + "//java/test/org/openqa/selenium/testing/drivers", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + artifact("org.jspecify:jspecify"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/module/BrowsingContextModuleTest.java b/java/test/org/openqa/selenium/bidi/protocol/module/BrowsingContextModuleTest.java new file mode 100644 index 0000000000000..e3c08d693a0af --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/module/BrowsingContextModuleTest.java @@ -0,0 +1,215 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.module; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.WindowType; +import org.openqa.selenium.bidi.BiDiException; +import org.openqa.selenium.bidi.protocol.browsingcontext.CloseParameters; +import org.openqa.selenium.bidi.protocol.browsingcontext.CreateParameters; +import org.openqa.selenium.bidi.protocol.browsingcontext.CreateResult; +import org.openqa.selenium.bidi.protocol.browsingcontext.CreateType; +import org.openqa.selenium.bidi.protocol.browsingcontext.GetTreeParameters; +import org.openqa.selenium.bidi.protocol.browsingcontext.GetTreeResult; +import org.openqa.selenium.bidi.protocol.browsingcontext.Info; +import org.openqa.selenium.bidi.protocol.browsingcontext.NavigateParameters; +import org.openqa.selenium.bidi.protocol.browsingcontext.NavigateResult; +import org.openqa.selenium.bidi.protocol.browsingcontext.NavigationInfo; +import org.openqa.selenium.bidi.protocol.browsingcontext.ReadinessState; +import org.openqa.selenium.testing.JupiterTestBase; +import org.openqa.selenium.testing.NeedsFreshDriver; + +class BrowsingContextModuleTest extends JupiterTestBase { + + @Test + @NeedsFreshDriver + void canCreateAWindow() { + BrowsingContext browsingContext = new BrowsingContext(driver); + + CreateResult result = browsingContext.create(new CreateParameters(CreateType.WINDOW)); + + assertThat(result.getContext()).isNotEmpty(); + } + + @Test + @NeedsFreshDriver + void canCreateATabWithAReferenceContext() { + BrowsingContext browsingContext = new BrowsingContext(driver); + + CreateResult result = + browsingContext.create( + new CreateParameters(CreateType.TAB).setReferenceContext(driver.getWindowHandle())); + + assertThat(result.getContext()).isNotEmpty(); + } + + @Test + @NeedsFreshDriver + void canNavigateToAUrl() { + BrowsingContext browsingContext = new BrowsingContext(driver); + String url = appServer.whereIs("/bidi/logEntryAdded.html"); + + NavigateResult result = + browsingContext.navigate( + new NavigateParameters(driver.getWindowHandle(), url).setWait(ReadinessState.COMPLETE)); + + assertThat(result.getUrl()).contains("/bidi/logEntryAdded.html"); + } + + @Test + @NeedsFreshDriver + void canGetTreeWithAChild() { + BrowsingContext browsingContext = new BrowsingContext(driver); + String referenceContextId = driver.getWindowHandle(); + String url = appServer.whereIs("iframes.html"); + + browsingContext.navigate( + new NavigateParameters(referenceContextId, url).setWait(ReadinessState.COMPLETE)); + + GetTreeResult result = browsingContext.getTree(new GetTreeParameters()); + + assertThat(result.getContexts()).hasSize(1); + Info info = result.getContexts().get(0); + assertThat(info.getChildren()).hasSize(1); + assertThat(info.getContext()).isEqualTo(referenceContextId); + } + + @Test + @NeedsFreshDriver + void canGetTreeWithDepthZeroOmitsChildren() { + BrowsingContext browsingContext = new BrowsingContext(driver); + String referenceContextId = driver.getWindowHandle(); + String url = appServer.whereIs("iframes.html"); + + browsingContext.navigate( + new NavigateParameters(referenceContextId, url).setWait(ReadinessState.COMPLETE)); + + GetTreeResult result = browsingContext.getTree(new GetTreeParameters().setMaxDepth(0L)); + + Info info = result.getContexts().get(0); + // Required + nullable (R4): the key is always present, but the browser sends an explicit + // null here since depth 0 means "don't include children." + assertThat(info.getChildren()).isNull(); + assertThat(info.getOriginalOpener()).isNull(); + assertThat(info.getUserContext()).isEqualTo("default"); + } + + @Test + @NeedsFreshDriver + void canGetAllTopLevelContexts() { + BrowsingContext browsingContext = new BrowsingContext(driver); + browsingContext.create(new CreateParameters(CreateType.WINDOW)); + + GetTreeResult result = browsingContext.getTree(new GetTreeParameters()); + + assertThat(result.getContexts()).hasSize(2); + } + + @Test + @NeedsFreshDriver + void canCloseAWindow() { + BrowsingContext browsingContext = new BrowsingContext(driver); + CreateResult window = browsingContext.create(new CreateParameters(CreateType.WINDOW)); + + browsingContext.close(new CloseParameters(window.getContext())); + + assertThatThrownBy( + () -> browsingContext.getTree(new GetTreeParameters().setRoot(window.getContext()))) + .isInstanceOf(BiDiException.class) + .hasMessageContaining("not found"); + } + + @Test + @NeedsFreshDriver + void canListenToWindowContextCreatedEvent() throws Exception { + BrowsingContext browsingContext = new BrowsingContext(driver); + CompletableFuture future = new CompletableFuture<>(); + browsingContext.subscribe(BrowsingContext.CONTEXT_CREATED, future::complete); + + String windowHandle = driver.switchTo().newWindow(WindowType.WINDOW).getWindowHandle(); + + Info info = future.get(5, TimeUnit.SECONDS); + assertThat(info.getContext()).isEqualTo(windowHandle); + assertThat(info.getUrl()).isEqualTo("about:blank"); + assertThat(info.getChildren()).isNull(); + } + + @Test + @NeedsFreshDriver + void canListenToBrowsingContextDestroyedEvent() throws Exception { + BrowsingContext browsingContext = new BrowsingContext(driver); + String windowHandle = driver.switchTo().newWindow(WindowType.WINDOW).getWindowHandle(); + + // CONTEXT_DESTROYED can only be subscribed globally (Module.subscribe has no per-context + // overload), so an unrelated context closing during the test — e.g. a browser onboarding tab + // some grid nodes auto-close shortly after launch — can also complete this. Filter to the + // context this test closed instead of completing on the first event received. + CompletableFuture future = new CompletableFuture<>(); + browsingContext.subscribe( + BrowsingContext.CONTEXT_DESTROYED, + info -> { + if (windowHandle.equals(info.getContext())) { + future.complete(info); + } + }); + + driver.close(); + + Info info = future.get(5, TimeUnit.SECONDS); + assertThat(info.getContext()).isEqualTo(windowHandle); + } + + @Test + @NeedsFreshDriver + void canListenToDomContentLoadedEvent() throws Exception { + BrowsingContext browsingContext = new BrowsingContext(driver); + CompletableFuture future = new CompletableFuture<>(); + browsingContext.subscribe(BrowsingContext.DOM_CONTENT_LOADED, future::complete); + + String contextId = driver.getWindowHandle(); + browsingContext.navigate( + new NavigateParameters(contextId, appServer.whereIs("/bidi/logEntryAdded.html")) + .setWait(ReadinessState.COMPLETE)); + + NavigationInfo navigationInfo = future.get(5, TimeUnit.SECONDS); + assertThat(navigationInfo.getContext()).isEqualTo(contextId); + assertThat(navigationInfo.getUrl()).contains("/bidi/logEntryAdded.html"); + } + + @Test + @NeedsFreshDriver + void canListenToNavigationStartedEvent() throws Exception { + BrowsingContext browsingContext = new BrowsingContext(driver); + CompletableFuture future = new CompletableFuture<>(); + browsingContext.subscribe(BrowsingContext.NAVIGATION_STARTED, future::complete); + + String contextId = driver.getWindowHandle(); + browsingContext.navigate( + new NavigateParameters(contextId, appServer.whereIs("/bidi/logEntryAdded.html")) + .setWait(ReadinessState.COMPLETE)); + + NavigationInfo navigationInfo = future.get(5, TimeUnit.SECONDS); + assertThat(navigationInfo.getContext()).isEqualTo(contextId); + assertThat(navigationInfo.getUrl()).contains("/bidi/logEntryAdded.html"); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/module/LogModuleTest.java b/java/test/org/openqa/selenium/bidi/protocol/module/LogModuleTest.java new file mode 100644 index 0000000000000..d0a53fb9db740 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/module/LogModuleTest.java @@ -0,0 +1,170 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.module; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.bidi.protocol.log.ConsoleLogEntry; +import org.openqa.selenium.bidi.protocol.log.Entry; +import org.openqa.selenium.bidi.protocol.log.JavascriptLogEntry; +import org.openqa.selenium.bidi.protocol.log.Level; +import org.openqa.selenium.bidi.protocol.script.StringValue; +import org.openqa.selenium.testing.JupiterTestBase; +import org.openqa.selenium.testing.NeedsFreshDriver; + +class LogModuleTest extends JupiterTestBase { + + private String page; + + @Test + @NeedsFreshDriver + void canListenToConsoleLogEntry() throws Exception { + Log log = new Log(driver); + CompletableFuture future = new CompletableFuture<>(); + log.subscribe( + Log.ENTRY_ADDED, + entry -> { + if (entry instanceof ConsoleLogEntry) { + future.complete((ConsoleLogEntry) entry); + } + }); + + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + driver.findElement(By.id("consoleLog")).click(); + + ConsoleLogEntry logEntry = future.get(5, TimeUnit.SECONDS); + assertThat(logEntry.getSource().getContext()).isPresent(); + assertThat(logEntry.getSource().getRealm()).isNotNull(); + assertThat(logEntry.getText()).isEqualTo("Hello, world!"); + assertThat(logEntry.getArgs()).hasSize(1); + assertThat(logEntry.getArgs().get(0)).isInstanceOf(StringValue.class); + assertThat(logEntry.getType()).isEqualTo("console"); + assertThat(logEntry.getLevel()).isEqualTo(Level.INFO); + assertThat(logEntry.getMethod()).isEqualTo("log"); + } + + @Test + @NeedsFreshDriver + void canListenToJavascriptLogEntry() throws Exception { + Log log = new Log(driver); + CompletableFuture future = new CompletableFuture<>(); + log.subscribe( + Log.ENTRY_ADDED, + entry -> { + if (entry instanceof JavascriptLogEntry) { + future.complete((JavascriptLogEntry) entry); + } + }); + + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + driver.findElement(By.id("jsException")).click(); + + JavascriptLogEntry logEntry = future.get(5, TimeUnit.SECONDS); + assertThat(logEntry.getSource().getContext()).isPresent(); + assertThat(logEntry.getSource().getRealm()).isNotNull(); + assertThat(logEntry.getText()).isEqualTo("Error: Not working"); + assertThat(logEntry.getType()).isEqualTo("javascript"); + assertThat(logEntry.getLevel()).isEqualTo(Level.ERROR); + } + + @Test + @NeedsFreshDriver + void canRetrieveStackTraceForALog() throws Exception { + Log log = new Log(driver); + CompletableFuture future = new CompletableFuture<>(); + log.subscribe( + Log.ENTRY_ADDED, + entry -> { + if (entry instanceof JavascriptLogEntry) { + future.complete((JavascriptLogEntry) entry); + } + }); + + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + driver.findElement(By.id("logWithStacktrace")).click(); + + JavascriptLogEntry logEntry = future.get(5, TimeUnit.SECONDS); + assertThat(logEntry.getStackTrace()).isPresent(); + assertThat(logEntry.getStackTrace().get().getCallFrames()).isNotEmpty(); + } + + @Test + @NeedsFreshDriver + void canListenToLogEntriesWithMultipleConsumers() throws Exception { + Log log = new Log(driver); + CompletableFuture future1 = new CompletableFuture<>(); + log.subscribe(Log.ENTRY_ADDED, future1::complete); + + CompletableFuture future2 = new CompletableFuture<>(); + log.subscribe(Log.ENTRY_ADDED, future2::complete); + + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + driver.findElement(By.id("consoleLog")).click(); + + Entry entry1 = future1.get(5, TimeUnit.SECONDS); + Entry entry2 = future2.get(5, TimeUnit.SECONDS); + + assertThat(entry1).isInstanceOf(ConsoleLogEntry.class); + assertThat(entry2).isInstanceOf(ConsoleLogEntry.class); + assertThat(((ConsoleLogEntry) entry1).getText()).isEqualTo("Hello, world!"); + assertThat(((ConsoleLogEntry) entry2).getText()).isEqualTo("Hello, world!"); + } + + @Test + @NeedsFreshDriver + void canUnsubscribeFromLogEntries() throws Exception { + Log log = new Log(driver); + AtomicInteger callCount = new AtomicInteger(); + CompletableFuture firstEvent = new CompletableFuture<>(); + String subscriptionId = + log.subscribe( + Log.ENTRY_ADDED, + entry -> { + callCount.incrementAndGet(); + firstEvent.complete(entry); + }); + + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + driver.findElement(By.id("consoleLog")).click(); + + assertThat(firstEvent.get(5, TimeUnit.SECONDS)).isInstanceOf(ConsoleLogEntry.class); + assertThat(callCount.get()).isEqualTo(1); + + log.unsubscribe(subscriptionId); + + // A second, still-active subscription acts as a synchronization point: once its event + // arrives, the browser has finished dispatching for this click, so it's safe to check whether + // the unsubscribed callback's counter moved. + CompletableFuture sentinel = new CompletableFuture<>(); + log.subscribe(Log.ENTRY_ADDED, sentinel::complete); + driver.findElement(By.id("consoleLog")).click(); + sentinel.get(5, TimeUnit.SECONDS); + + assertThat(callCount.get()).isEqualTo(1); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/module/NetworkEventsTest.java b/java/test/org/openqa/selenium/bidi/protocol/module/NetworkEventsTest.java new file mode 100644 index 0000000000000..f1ef81217ac7d --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/module/NetworkEventsTest.java @@ -0,0 +1,179 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.module; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openqa.selenium.testing.drivers.Browser.CHROME; +import static org.openqa.selenium.testing.drivers.Browser.EDGE; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.Cookie; +import org.openqa.selenium.WebDriverException; +import org.openqa.selenium.bidi.protocol.network.AuthRequiredParameters; +import org.openqa.selenium.bidi.protocol.network.BeforeRequestSentParameters; +import org.openqa.selenium.bidi.protocol.network.FetchErrorParameters; +import org.openqa.selenium.bidi.protocol.network.ResponseCompletedParameters; +import org.openqa.selenium.bidi.protocol.network.ResponseStartedParameters; +import org.openqa.selenium.bidi.protocol.network.StringValue; +import org.openqa.selenium.testing.JupiterTestBase; +import org.openqa.selenium.testing.NeedsFreshDriver; +import org.openqa.selenium.testing.NotYetImplemented; +import org.openqa.selenium.testing.Pages; + +class NetworkEventsTest extends JupiterTestBase { + + private String page; + + @Test + @NeedsFreshDriver + void canListenToBeforeRequestSentEvent() + throws ExecutionException, InterruptedException, TimeoutException { + Network network = new Network(driver); + CompletableFuture future = new CompletableFuture<>(); + network.subscribe(Network.BEFORE_REQUEST_SENT, future::complete); + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + + BeforeRequestSentParameters requestSent = future.get(5, TimeUnit.SECONDS); + String windowHandle = driver.getWindowHandle(); + assertThat(requestSent.getContext()).isEqualTo(windowHandle); + assertThat(requestSent.getRequest().getRequest()).isNotNull(); + assertThat(requestSent.getRequest().getMethod()).isEqualToIgnoringCase("get"); + assertThat(requestSent.getRequest().getUrl()).isNotNull(); + assertThat(requestSent.getInitiator()).isPresent(); + assertThat(requestSent.getInitiator().get().getType()).isPresent(); + assertThat(requestSent.getInitiator().get().getType().get().toString()) + .isEqualToIgnoringCase("other"); + } + + @Test + @NeedsFreshDriver + void canListenToResponseStartedEvent() + throws ExecutionException, InterruptedException, TimeoutException { + Network network = new Network(driver); + CompletableFuture future = new CompletableFuture<>(); + network.subscribe(Network.RESPONSE_STARTED, future::complete); + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + + ResponseStartedParameters response = future.get(5, TimeUnit.SECONDS); + String windowHandle = driver.getWindowHandle(); + assertThat(response.getContext()).isEqualTo(windowHandle); + assertThat(response.getRequest().getRequest()).isNotNull(); + assertThat(response.getRequest().getMethod()).isEqualToIgnoringCase("get"); + assertThat(response.getRequest().getUrl()).isNotNull(); + assertThat(response.getResponse().getHeaders().size()).isGreaterThanOrEqualTo(1); + assertThat(response.getResponse().getUrl()).contains("/bidi/logEntryAdded.html"); + assertThat(response.getResponse().getStatus()).isEqualTo(200); + } + + @Test + @NeedsFreshDriver + void canListenToResponseCompletedEvent() + throws ExecutionException, InterruptedException, TimeoutException { + Network network = new Network(driver); + CompletableFuture future = new CompletableFuture<>(); + network.subscribe(Network.RESPONSE_COMPLETED, future::complete); + page = appServer.whereIs("/bidi/logEntryAdded.html"); + driver.get(page); + + ResponseCompletedParameters response = future.get(5, TimeUnit.SECONDS); + String windowHandle = driver.getWindowHandle(); + assertThat(response.getContext()).isEqualTo(windowHandle); + assertThat(response.getRequest().getRequest()).isNotNull(); + assertThat(response.getRequest().getMethod()).isEqualToIgnoringCase("get"); + assertThat(response.getRequest().getUrl()).isNotNull(); + assertThat(response.getResponse().getHeaders().size()).isGreaterThanOrEqualTo(1); + assertThat(response.getResponse().getUrl()).contains("/bidi/logEntryAdded.html"); + assertThat(response.getResponse().getStatus()).isEqualTo(200); + } + + @Test + @NeedsFreshDriver + void canListenToResponseCompletedEventWithCookie() + throws ExecutionException, InterruptedException, TimeoutException { + Network network = new Network(driver); + CompletableFuture future = new CompletableFuture<>(); + + driver.get(new Pages(appServer).blankPage); + driver.manage().addCookie(new Cookie("foo", "bar")); + network.subscribe(Network.BEFORE_REQUEST_SENT, future::complete); + driver.navigate().refresh(); + + BeforeRequestSentParameters requestSent = future.get(5, TimeUnit.SECONDS); + String windowHandle = driver.getWindowHandle(); + assertThat(requestSent.getContext()).isEqualTo(windowHandle); + assertThat(requestSent.getRequest().getCookies()).hasSize(1); + assertThat(requestSent.getRequest().getCookies().get(0).getName()).isEqualTo("foo"); + assertThat(((StringValue) requestSent.getRequest().getCookies().get(0).getValue()).getValue()) + .isEqualTo("bar"); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(EDGE) + @NotYetImplemented(CHROME) + void canListenToOnAuthRequiredEvent() + throws ExecutionException, InterruptedException, TimeoutException { + Network network = new Network(driver); + CompletableFuture future = new CompletableFuture<>(); + network.subscribe(Network.AUTH_REQUIRED, future::complete); + page = appServer.whereIs("basicAuth"); + driver.get(page); + + AuthRequiredParameters response = future.get(5, TimeUnit.SECONDS); + String windowHandle = driver.getWindowHandle(); + assertThat(response.getContext()).isEqualTo(windowHandle); + assertThat(response.getRequest().getRequest()).isNotNull(); + assertThat(response.getRequest().getMethod()).isEqualToIgnoringCase("get"); + assertThat(response.getRequest().getUrl()).isNotNull(); + assertThat(response.getResponse().getHeaders().size()).isGreaterThanOrEqualTo(1); + assertThat(response.getResponse().getUrl()).contains("basicAuth"); + assertThat(response.getResponse().getStatus()).isEqualTo(401); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(EDGE) + @NotYetImplemented(CHROME) + void canListenToFetchError() throws ExecutionException, InterruptedException, TimeoutException { + Network network = new Network(driver); + CompletableFuture future = new CompletableFuture<>(); + network.subscribe(Network.FETCH_ERROR, future::complete); + page = appServer.whereIs("error"); + try { + driver.get("https://not_a_valid_url.test/"); + } catch (WebDriverException ignored) { + // Expected — the navigation itself fails; we only care about the BiDi event it produces. + } + + FetchErrorParameters fetchError = future.get(5, TimeUnit.SECONDS); + String windowHandle = driver.getWindowHandle(); + assertThat(fetchError.getContext()).isEqualTo(windowHandle); + assertThat(fetchError.getRequest().getRequest()).isNotNull(); + assertThat(fetchError.getRequest().getMethod()).isEqualToIgnoringCase("get"); + assertThat(fetchError.getRequest().getUrl()).contains("https://not_a_valid_url.test/"); + assertThat(fetchError.getRequest().getHeaders().size()).isGreaterThanOrEqualTo(1); + assertThat(fetchError.getNavigation()).isNotNull(); + assertThat(fetchError.getErrorText()).contains("UNKNOWN_HOST"); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/module/NetworkModuleTest.java b/java/test/org/openqa/selenium/bidi/protocol/module/NetworkModuleTest.java new file mode 100644 index 0000000000000..964b1b6a75757 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/module/NetworkModuleTest.java @@ -0,0 +1,72 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.module; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.bidi.protocol.network.AddInterceptParameters; +import org.openqa.selenium.bidi.protocol.network.AddInterceptResult; +import org.openqa.selenium.bidi.protocol.network.BeforeRequestSentParameters; +import org.openqa.selenium.bidi.protocol.network.InterceptPhase; +import org.openqa.selenium.bidi.protocol.network.RemoveInterceptParameters; +import org.openqa.selenium.testing.JupiterTestBase; +import org.openqa.selenium.testing.NeedsFreshDriver; + +class NetworkModuleTest extends JupiterTestBase { + + @Test + @NeedsFreshDriver + void canAddAndRemoveInterceptThroughTheGeneratedModule() { + Network network = new Network(driver); + + AddInterceptResult result = + network.addIntercept(new AddInterceptParameters(List.of(InterceptPhase.BEFOREREQUESTSENT))); + + assertThat(result.getIntercept()).isNotNull(); + + network.removeIntercept(new RemoveInterceptParameters(result.getIntercept())); + } + + @Test + @NeedsFreshDriver + void canSubscribeReceiveAndUnsubscribeFromAGeneratedEvent() throws Exception { + // Deliberately no interception here: this test is only about proving the subscribe -> + // receive -> unsubscribe lifecycle works, not about the intercept/continue command flow + // (covered separately in canAddAndRemoveInterceptThroughTheGeneratedModule). Combining + // both in one test means every beforeRequestSent request blocks until explicitly continued, + // which turns "did the event arrive" into a much harder, unrelated problem to get right. + Network network = new Network(driver); + CompletableFuture future = new CompletableFuture<>(); + + String subscriptionId = network.subscribe(Network.BEFORE_REQUEST_SENT, future::complete); + assertThat(subscriptionId).isNotNull(); + + driver.get(appServer.whereIs("/bidi/logEntryAdded.html")); + + BeforeRequestSentParameters event = future.get(5, TimeUnit.SECONDS); + assertThat(event.getContext()).isEqualTo(driver.getWindowHandle()); + assertThat(event.getRequest().getMethod()).isEqualToIgnoringCase("get"); + assertThat(event.getRequest().getUrl()).isNotNull(); + + network.unsubscribe(subscriptionId); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/module/ScriptModuleTest.java b/java/test/org/openqa/selenium/bidi/protocol/module/ScriptModuleTest.java new file mode 100644 index 0000000000000..efa067716fef2 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/module/ScriptModuleTest.java @@ -0,0 +1,225 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.module; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openqa.selenium.testing.drivers.Browser.CHROME; +import static org.openqa.selenium.testing.drivers.Browser.EDGE; +import static org.openqa.selenium.testing.drivers.Browser.FIREFOX; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.bidi.protocol.script.AddPreloadScriptParameters; +import org.openqa.selenium.bidi.protocol.script.AddPreloadScriptResult; +import org.openqa.selenium.bidi.protocol.script.ArrayRemoteValue; +import org.openqa.selenium.bidi.protocol.script.CallFunctionParameters; +import org.openqa.selenium.bidi.protocol.script.ChannelProperties; +import org.openqa.selenium.bidi.protocol.script.ChannelValue; +import org.openqa.selenium.bidi.protocol.script.ContextTarget; +import org.openqa.selenium.bidi.protocol.script.EvaluateParameters; +import org.openqa.selenium.bidi.protocol.script.EvaluateResult; +import org.openqa.selenium.bidi.protocol.script.EvaluateResultException; +import org.openqa.selenium.bidi.protocol.script.EvaluateResultSuccess; +import org.openqa.selenium.bidi.protocol.script.GetRealmsParameters; +import org.openqa.selenium.bidi.protocol.script.GetRealmsResult; +import org.openqa.selenium.bidi.protocol.script.MessageParameters; +import org.openqa.selenium.bidi.protocol.script.NumberValue; +import org.openqa.selenium.bidi.protocol.script.RealmDestroyedParameters; +import org.openqa.selenium.bidi.protocol.script.RealmInfo; +import org.openqa.selenium.bidi.protocol.script.RemoteValue; +import org.openqa.selenium.bidi.protocol.script.RemovePreloadScriptParameters; +import org.openqa.selenium.bidi.protocol.script.StringValue; +import org.openqa.selenium.bidi.protocol.script.WindowRealmInfo; +import org.openqa.selenium.testing.JupiterTestBase; +import org.openqa.selenium.testing.NeedsFreshDriver; +import org.openqa.selenium.testing.NotYetImplemented; +import org.openqa.selenium.testing.Pages; + +class ScriptModuleTest extends JupiterTestBase { + + @Test + @NeedsFreshDriver + void canCallFunctionWithDeclaration() { + Script script = new Script(driver); + ContextTarget target = new ContextTarget(driver.getWindowHandle()); + + EvaluateResult result = + script.callFunction(new CallFunctionParameters("()=>{return 1+2;}", false, target)); + + assertThat(result).isInstanceOf(EvaluateResultSuccess.class); + EvaluateResultSuccess success = (EvaluateResultSuccess) result; + assertThat(success.getRealm()).isNotNull(); + assertThat(success.getResult()).isInstanceOf(NumberValue.class); + assertThat(((NumberValue) success.getResult()).getValue()).isEqualTo(3L); + } + + @Test + @NeedsFreshDriver + void canCallFunctionWithArguments() { + Script script = new Script(driver); + ContextTarget target = new ContextTarget(driver.getWindowHandle()); + + EvaluateResult result = + script.callFunction( + new CallFunctionParameters("(...args)=>{return args}", false, target) + .setArguments( + List.of( + new StringValue("string", "ARGUMENT_STRING_VALUE"), + new NumberValue("number", 42L)))); + + assertThat(result).isInstanceOf(EvaluateResultSuccess.class); + EvaluateResultSuccess success = (EvaluateResultSuccess) result; + assertThat(success.getResult()).isInstanceOf(ArrayRemoteValue.class); + List args = ((ArrayRemoteValue) success.getResult()).getValue().orElseThrow(); + assertThat(args).hasSize(2); + assertThat(((StringValue) args.get(0)).getValue()).isEqualTo("ARGUMENT_STRING_VALUE"); + assertThat(((NumberValue) args.get(1)).getValue()).isEqualTo(42L); + } + + @Test + @NeedsFreshDriver + void canCallFunctionWithAwaitPromise() { + Script script = new Script(driver); + ContextTarget target = new ContextTarget(driver.getWindowHandle()); + + EvaluateResult result = + script.callFunction( + new CallFunctionParameters( + "async function() {" + + " await new Promise(r => setTimeout(() => r(), 0));" + + " return \"SOME_DELAYED_RESULT\";" + + "}", + true, + target)); + + assertThat(result).isInstanceOf(EvaluateResultSuccess.class); + EvaluateResultSuccess success = (EvaluateResultSuccess) result; + assertThat(success.getResult()).isInstanceOf(StringValue.class); + assertThat(((StringValue) success.getResult()).getValue()).isEqualTo("SOME_DELAYED_RESULT"); + } + + @Test + @NeedsFreshDriver + void canCallFunctionThatThrowsException() { + Script script = new Script(driver); + ContextTarget target = new ContextTarget(driver.getWindowHandle()); + + EvaluateResult result = + script.callFunction( + new CallFunctionParameters(")))!!@@## some invalid JS script (((", false, target)); + + assertThat(result).isInstanceOf(EvaluateResultException.class); + EvaluateResultException exception = (EvaluateResultException) result; + assertThat(exception.getRealm()).isNotNull(); + assertThat(exception.getExceptionDetails().getException()).isInstanceOf(RemoteValue.class); + assertThat(exception.getExceptionDetails().getText()).contains("SyntaxError"); + assertThat(exception.getExceptionDetails().getLineNumber()).isPositive(); + assertThat(exception.getExceptionDetails().getColumnNumber()).isPositive(); + } + + @Test + @NeedsFreshDriver + void canEvaluateScript() { + Script script = new Script(driver); + ContextTarget target = new ContextTarget(driver.getWindowHandle()); + + EvaluateResult result = script.evaluate(new EvaluateParameters("1 + 2", target, true)); + + assertThat(result).isInstanceOf(EvaluateResultSuccess.class); + EvaluateResultSuccess success = (EvaluateResultSuccess) result; + assertThat(success.getResult()).isInstanceOf(NumberValue.class); + assertThat(((NumberValue) success.getResult()).getValue()).isEqualTo(3L); + } + + @Test + @NeedsFreshDriver + void canGetRealms() { + Script script = new Script(driver); + + GetRealmsResult result = script.getRealms(new GetRealmsParameters()); + + assertThat(result.getRealms()).isNotEmpty(); + assertThat(result.getRealms().get(0)).isInstanceOf(WindowRealmInfo.class); + } + + @Test + @NeedsFreshDriver + void canAddAndRemovePreloadScript() { + Script script = new Script(driver); + + AddPreloadScriptResult addResult = + script.addPreloadScript(new AddPreloadScriptParameters("() => {}")); + assertThat(addResult.getScript()).isNotNull(); + + script.removePreloadScript(new RemovePreloadScriptParameters(addResult.getScript())); + } + + @Test + @NeedsFreshDriver + void canListenToChannelMessage() throws Exception { + Script script = new Script(driver); + CompletableFuture future = new CompletableFuture<>(); + script.subscribe(Script.MESSAGE, future::complete); + + script.callFunction( + new CallFunctionParameters( + "(channel) => channel('foo')", false, new ContextTarget(driver.getWindowHandle())) + .setArguments( + List.of(new ChannelValue("channel", new ChannelProperties("channel_name"))))); + + MessageParameters message = future.get(5, TimeUnit.SECONDS); + assertThat(message.getChannel()).isEqualTo("channel_name"); + assertThat(message.getData()).isInstanceOf(StringValue.class); + assertThat(((StringValue) message.getData()).getValue()).isEqualTo("foo"); + assertThat(message.getSource().getRealm()).isNotNull(); + assertThat(message.getSource().getContext()).contains(driver.getWindowHandle()); + } + + @Test + @NeedsFreshDriver + void canListenToRealmCreatedEvent() throws Exception { + Script script = new Script(driver); + CompletableFuture future = new CompletableFuture<>(); + script.subscribe(Script.REALM_CREATED, future::complete); + + driver.get(new Pages(appServer).blankPage); + + RealmInfo realmInfo = future.get(5, TimeUnit.SECONDS); + assertThat(realmInfo).isInstanceOf(WindowRealmInfo.class); + assertThat(((WindowRealmInfo) realmInfo).getRealm()).isNotNull(); + assertThat(((WindowRealmInfo) realmInfo).getType()).isEqualTo("window"); + } + + @Test + @NeedsFreshDriver + @NotYetImplemented(CHROME) + @NotYetImplemented(EDGE) + @NotYetImplemented(FIREFOX) + void canListenToRealmDestroyedEvent() throws Exception { + Script script = new Script(driver); + CompletableFuture future = new CompletableFuture<>(); + script.subscribe(Script.REALM_DESTROYED, future::complete); + + driver.close(); + + RealmDestroyedParameters realmDestroyed = future.get(5, TimeUnit.SECONDS); + assertThat(realmDestroyed.getRealm()).isNotNull(); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/network/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/network/BUILD.bazel new file mode 100644 index 0000000000000..6352d9b3d36c5 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/network/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "SmallTests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/json", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/network/ContinueWithAuthParametersTest.java b/java/test/org/openqa/selenium/bidi/protocol/network/ContinueWithAuthParametersTest.java new file mode 100644 index 0000000000000..5d92fc3736976 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/network/ContinueWithAuthParametersTest.java @@ -0,0 +1,101 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.network; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.json.Json; + +@Tag("UnitTests") +class ContinueWithAuthParametersTest { + + @SuppressWarnings("unchecked") + private static Map mapOf(String raw) { + return (Map) new Json().toType(raw, Json.MAP_TYPE); + } + + @Test + void dispatchesToCredentialsWhenActionIsProvideCredentials() { + Map map = + mapOf( + "{\"request\": \"req-1\", \"action\": \"provideCredentials\"," + + " \"credentials\": {\"type\": \"password\", \"username\": \"u\", \"password\":" + + " \"p\"}}"); + + ContinueWithAuthParameters result = ContinueWithAuthParameters.fromMap(map); + + assertThat(result).isInstanceOf(ContinueWithAuthParameters.Credentials.class); + ContinueWithAuthParameters.Credentials creds = (ContinueWithAuthParameters.Credentials) result; + assertThat(creds.getRequest()).isEqualTo("req-1"); + assertThat(creds.getCredentials().getUsername()).isEqualTo("u"); + assertThat(creds.getCredentials().getPassword()).isEqualTo("p"); + } + + @Test + void dispatchesToNoCredentialsWhenActionIsCancel() { + Map map = mapOf("{\"request\": \"req-2\", \"action\": \"cancel\"}"); + + ContinueWithAuthParameters result = ContinueWithAuthParameters.fromMap(map); + + assertThat(result).isInstanceOf(ContinueWithAuthParameters.NoCredentials.class); + ContinueWithAuthParameters.NoCredentials noCreds = + (ContinueWithAuthParameters.NoCredentials) result; + assertThat(noCreds.getAction()) + .isEqualTo(ContinueWithAuthParameters.NoCredentials.Action.CANCEL); + } + + @Test + void anyUnrecognizedActionFallsBackToNoCredentialsPerTheSchemasDefaultVariant() { + // network.ContinueWithAuthParameters's selector explicitly names NoCredentials as its + // "default" variant for any value other than "provideCredentials" — this is intentional, + // not a swallowed error, so an unmodeled action string should still dispatch cleanly rather + // than throwing. + Map map = mapOf("{\"request\": \"req-3\", \"action\": \"default\"}"); + + ContinueWithAuthParameters result = ContinueWithAuthParameters.fromMap(map); + + assertThat(result).isInstanceOf(ContinueWithAuthParameters.NoCredentials.class); + } + + @Test + void nestedActionEnumRoundTripsThroughItsWireValue() { + assertThat(ContinueWithAuthParameters.NoCredentials.Action.fromString("cancel")) + .isEqualTo(ContinueWithAuthParameters.NoCredentials.Action.CANCEL); + assertThat(ContinueWithAuthParameters.NoCredentials.Action.CANCEL.toString()) + .isEqualTo("cancel"); + assertThat(ContinueWithAuthParameters.NoCredentials.Action.fromString("default")) + .isEqualTo(ContinueWithAuthParameters.NoCredentials.Action.DEFAULT); + } + + @Test + void credentialsVariantSerializesBackToTheExpectedWireShape() { + ContinueWithAuthParameters.Credentials creds = + new ContinueWithAuthParameters.Credentials( + "req-1", "provideCredentials", new AuthCredentials("password", "u", "p")); + + Map map = creds.toMap(); + + assertThat(map).containsEntry("request", "req-1"); + assertThat(map).containsEntry("action", "provideCredentials"); + assertThat(map.get("credentials")) + .isEqualTo(Map.of("type", "password", "username", "u", "password", "p")); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/network/CookieTest.java b/java/test/org/openqa/selenium/bidi/protocol/network/CookieTest.java new file mode 100644 index 0000000000000..73b78ad9503c4 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/network/CookieTest.java @@ -0,0 +1,73 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.network; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.json.Json; + +@Tag("UnitTests") +class CookieTest { + + private static final String BASE_FIELDS = + "\"name\": \"sid\", \"value\": {\"type\": \"string\", \"value\": \"abc\"}," + + " \"domain\": \"example.com\", \"path\": \"/\", \"size\": 6," + + " \"httpOnly\": false, \"secure\": true, \"sameSite\": \"strict\""; + + @Test + void undeclaredWireFieldIsPreservedAsAnExtension() { + // network.Cookie is extensible but receive-only (Selenium sets cookies through the + // differently-typed storage.PartialCookie), so this confirms extras get kept regardless of + // whether the type can also be sent back out. + Cookie cookie = new Json().toType("{" + BASE_FIELDS + ", \"sameParty\": true}", Cookie.class); + + assertThat(cookie.getName()).isEqualTo("sid"); + assertThat(cookie.getExtensions()).containsExactly(Map.entry("sameParty", true)); + } + + @Test + void multipleUndeclaredFieldsAreAllPreserved() { + Cookie cookie = + new Json() + .toType( + "{" + BASE_FIELDS + ", \"sameParty\": true, \"partitionKey\": \"top-level\"}", + Cookie.class); + + assertThat(cookie.getExtensions()) + .containsExactly(Map.entry("sameParty", true), Map.entry("partitionKey", "top-level")); + } + + @Test + void noUndeclaredFieldsMeansAnEmptyExtensionsMap() { + Cookie cookie = new Json().toType("{" + BASE_FIELDS + "}", Cookie.class); + + assertThat(cookie.getExtensions()).isEmpty(); + } + + @Test + void extensionsMapIsUnmodifiable() { + Cookie cookie = new Json().toType("{" + BASE_FIELDS + "}", Cookie.class); + + assertThatThrownBy(() -> cookie.getExtensions().put("x", "y")) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/script/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/script/BUILD.bazel new file mode 100644 index 0000000000000..c3e40a2d57382 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/script/BUILD.bazel @@ -0,0 +1,15 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "SmallTests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/bidi", + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/json", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/script/SharedReferenceTest.java b/java/test/org/openqa/selenium/bidi/protocol/script/SharedReferenceTest.java new file mode 100644 index 0000000000000..0080a38e55597 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/script/SharedReferenceTest.java @@ -0,0 +1,86 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.script; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.bidi.BiDiException; +import org.openqa.selenium.json.Json; + +@Tag("UnitTests") +class SharedReferenceTest { + + // script.SharedReference is both sent (as a script argument) and received (inside a + // RemoteValue), so it is the bidirectional case: addExtension lives on the Builder, and + // Builder.build() and the deserializer's fromJson both funnel through the same shared, + // validated constructor. + + @Test + void builderAddedExtensionAppearsOnTheBuiltInstanceAndOnTheWire() { + SharedReference ref = + SharedReference.builder("shared-1").addExtension("vendorHint", "chrome").build(); + + assertThat(ref.getSharedId()).isEqualTo("shared-1"); + assertThat(ref.getExtensions()).containsExactly(Map.entry("vendorHint", "chrome")); + assertThat(ref.toMap()).containsEntry("vendorHint", "chrome"); + } + + @Test + void reusingTheBuilderAfterBuildDoesNotMutateThePreviouslyBuiltInstance() { + SharedReference.Builder builder = + SharedReference.builder("shared-1").addExtension("vendorHint", "chrome"); + + SharedReference first = builder.build(); + builder.addExtension("secondHint", "firefox"); + + assertThat(first.getExtensions()).containsExactly(Map.entry("vendorHint", "chrome")); + assertThat(first.toMap()).containsEntry("vendorHint", "chrome").doesNotContainKey("secondHint"); + } + + @Test + void builderRejectsAnExtensionThatShadowsADeclaredField() { + SharedReference.Builder builder = SharedReference.builder("shared-1"); + + assertThatThrownBy(() -> builder.addExtension("sharedId", "collides")) + .isInstanceOf(BiDiException.class) + .hasMessageContaining("sharedId"); + } + + @Test + void undeclaredWireFieldOnAReceivedInstanceIsPreservedAsAnExtension() { + SharedReference ref = + new Json() + .toType( + "{\"sharedId\": \"shared-2\", \"handle\": \"h1\", \"vendorHint\": \"firefox\"}", + SharedReference.class); + + assertThat(ref.getHandle()).contains("h1"); + assertThat(ref.getExtensions()).containsExactly(Map.entry("vendorHint", "firefox")); + } + + @Test + void aReceivedInstanceWithNoUndeclaredFieldsHasEmptyExtensions() { + SharedReference ref = new Json().toType("{\"sharedId\": \"shared-3\"}", SharedReference.class); + + assertThat(ref.getExtensions()).isEmpty(); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/session/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/session/BUILD.bazel new file mode 100644 index 0000000000000..6352d9b3d36c5 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/session/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "SmallTests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/json", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/session/UserPromptHandlerTest.java b/java/test/org/openqa/selenium/bidi/protocol/session/UserPromptHandlerTest.java new file mode 100644 index 0000000000000..14be66b7b8954 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/session/UserPromptHandlerTest.java @@ -0,0 +1,42 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.session; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.json.Json; + +@Tag("UnitTests") +class UserPromptHandlerTest { + + @Test + void wireKeyDefaultDeserializesIntoTheEscapedDefault_Field() { + // "default" is a Java reserved word, so the generator escapes the Java identifier to + // "default_" while keeping the wire key "default" — this must still round-trip correctly. + Json json = new Json(); + UserPromptHandler handler = + json.toType("{\"default\":\"accept\",\"alert\":\"dismiss\"}", UserPromptHandler.class); + + assertThat(handler.getDefault_()).isPresent(); + assertThat(handler.getDefault_().get()).isEqualTo(UserPromptHandlerType.ACCEPT); + assertThat(handler.getAlert()).isPresent(); + assertThat(handler.getAlert().get()).isEqualTo(UserPromptHandlerType.DISMISS); + } +} diff --git a/java/test/org/openqa/selenium/bidi/protocol/storage/BUILD.bazel b/java/test/org/openqa/selenium/bidi/protocol/storage/BUILD.bazel new file mode 100644 index 0000000000000..c3e40a2d57382 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/storage/BUILD.bazel @@ -0,0 +1,15 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite") + +java_test_suite( + name = "SmallTests", + size = "small", + srcs = glob(["*Test.java"]), + deps = [ + "//java/src/org/openqa/selenium/bidi", + "//java/src/org/openqa/selenium/bidi:bidi-generated", + "//java/src/org/openqa/selenium/json", + artifact("org.junit.jupiter:junit-jupiter-api"), + artifact("org.assertj:assertj-core"), + ] + JUNIT5_DEPS, +) diff --git a/java/test/org/openqa/selenium/bidi/protocol/storage/PartialCookieTest.java b/java/test/org/openqa/selenium/bidi/protocol/storage/PartialCookieTest.java new file mode 100644 index 0000000000000..966c0d03f37a6 --- /dev/null +++ b/java/test/org/openqa/selenium/bidi/protocol/storage/PartialCookieTest.java @@ -0,0 +1,68 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.bidi.protocol.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.bidi.BiDiException; +import org.openqa.selenium.bidi.protocol.network.StringValue; + +@Tag("UnitTests") +class PartialCookieTest { + + private static PartialCookie cookie() { + return new PartialCookie("sid", new StringValue("string", "abc"), "example.com"); + } + + @Test + void addedExtensionIsSerializedOntoTheWireAlongsideDeclaredFields() { + // storage.PartialCookie is extensible and sendable, so an added extra field should reach the + // wire alongside the declared ones. + Map map = cookie().addExtension("sameParty", true).toMap(); + + assertThat(map).containsEntry("name", "sid"); + assertThat(map).containsEntry("domain", "example.com"); + assertThat(map).containsEntry("sameParty", true); + } + + @Test + void addExtensionReturnsThisForFluentChaining() { + PartialCookie built = cookie().addExtension("a", 1).addExtension("b", 2); + + assertThat(built.getExtensions()).containsExactly(Map.entry("a", 1), Map.entry("b", 2)); + } + + @Test + void addingAnExtensionForAnAlreadyDeclaredFieldIsRejected() { + // A caller-added extension must never shadow a declared field's wire key. + assertThatThrownBy(() -> cookie().addExtension("name", "collides")) + .isInstanceOf(BiDiException.class) + .hasMessageContaining("name"); + } + + @Test + void noExtensionsAddedMeansNoExtraWireKeys() { + Map map = cookie().toMap(); + + assertThat(map).containsOnlyKeys("name", "value", "domain"); + } +}