Skip to content

Forward compatible enums #476

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55,703 changes: 33,246 additions & 22,457 deletions api/chrome-devtools-kotlin.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,49 +1,136 @@
package org.hildan.chrome.devtools.protocol.generator

import com.squareup.kotlinpoet.*
import kotlinx.serialization.*
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import org.hildan.chrome.devtools.protocol.model.ChromeDPDomain
import org.hildan.chrome.devtools.protocol.model.ChromeDPType
import org.hildan.chrome.devtools.protocol.model.DomainTypeDeclaration
import org.hildan.chrome.devtools.protocol.names.Annotations
import org.hildan.chrome.devtools.protocol.names.ExtDeclarations
import org.hildan.chrome.devtools.protocol.names.UndefinedEnumEntryName

fun ChromeDPDomain.createDomainTypesFileSpec(): FileSpec =
FileSpec.builder(packageName = names.packageName, fileName = names.typesFilename).apply {
addAnnotation(Annotations.suppressWarnings)
types.forEach { addDomainType(it) }
types.forEach { addDomainType(it, experimentalDomain = experimental) }
}.build()

private fun FileSpec.Builder.addDomainType(typeDeclaration: DomainTypeDeclaration) {
private fun FileSpec.Builder.addDomainType(typeDeclaration: DomainTypeDeclaration, experimentalDomain: Boolean) {
when (val type = typeDeclaration.type) {
is ChromeDPType.Object -> addType(typeDeclaration.toDataClassTypeSpec(type))
is ChromeDPType.Enum -> addType(typeDeclaration.toEnumTypeSpec(type))
is ChromeDPType.Enum -> addTypes(typeDeclaration.toEnumAndSerializerTypeSpecs(type, experimentalDomain))
is ChromeDPType.NamedRef -> addTypeAlias(typeDeclaration.toTypeAliasSpec(type))
}
}

private fun DomainTypeDeclaration.toDataClassTypeSpec(type: ChromeDPType.Object): TypeSpec =
TypeSpec.classBuilder(names.declaredName).apply {
TypeSpec.classBuilder(names.className).apply {
addModifiers(KModifier.DATA)
addCommonConfig(this@toDataClassTypeSpec)
addKDocAndStabilityAnnotations(element = this@toDataClassTypeSpec)
addAnnotation(Annotations.serializable)
addPrimaryConstructorProps(type.properties)
}.build()

private fun DomainTypeDeclaration.toEnumTypeSpec(type: ChromeDPType.Enum): TypeSpec =
TypeSpec.enumBuilder(names.declaredName).apply {
addCommonConfig(this@toEnumTypeSpec)
private fun DomainTypeDeclaration.toEnumAndSerializerTypeSpecs(type: ChromeDPType.Enum, experimentalDomain: Boolean): List<TypeSpec> =
if (experimental || experimentalDomain) {
val serializerTypeSpec = serializerForFCEnum(names.className, type.enumValues)
val serializerClass = ClassName(names.packageName, serializerTypeSpec.name!!)
listOf(serializerTypeSpec, toFCEnumTypeSpec(type, serializerClass))
} else {
listOf(toStableEnumTypeSpec(type))
}

private fun DomainTypeDeclaration.toStableEnumTypeSpec(type: ChromeDPType.Enum): TypeSpec =
TypeSpec.enumBuilder(names.className).apply {
addKDocAndStabilityAnnotations(element = this@toStableEnumTypeSpec)
type.enumValues.forEach {
val enumValueKotlinName = it.dashesToCamelCase()
val serialNameAnnotation = AnnotationSpec.builder(SerialName::class).addMember("%S", it).build()
addEnumConstant(enumValueKotlinName, TypeSpec.anonymousClassBuilder().addAnnotation(serialNameAnnotation).build())
addEnumConstant(
name = protocolEnumEntryNameToKotlinName(it),
typeSpec = TypeSpec.anonymousClassBuilder().addAnnotation(Annotations.serialName(it)).build()
)
}
addAnnotation(Annotations.serializable)
}.build()

private fun String.dashesToCamelCase(): String = replace(Regex("""-(\w)""")) { it.groupValues[1].uppercase() }
private fun DomainTypeDeclaration.toFCEnumTypeSpec(type: ChromeDPType.Enum, serializerClass: ClassName): TypeSpec =
TypeSpec.interfaceBuilder(names.className).apply {
addModifiers(KModifier.SEALED)
addKDocAndStabilityAnnotations(element = this@toFCEnumTypeSpec)
addAnnotation(Annotations.serializableWith(serializerClass))

private fun TypeSpec.Builder.addCommonConfig(domainTypeDeclaration: DomainTypeDeclaration) {
addKDocAndStabilityAnnotations(domainTypeDeclaration)
addAnnotation(Annotations.serializable)
}
type.enumValues.forEach {
addType(TypeSpec.objectBuilder(protocolEnumEntryNameToKotlinName(it)).apply {
addModifiers(KModifier.DATA)
addSuperinterface(names.className)

// For calls to serializers made directly with this sub-object instead of the FC enum's interface.
// Example: Json.encodeToString(AXPropertyName.url)
// (and not Json.encodeToString<AXPropertyName>(AXPropertyName.url))
addAnnotation(Annotations.serializableWith(serializerClass))
}.build())
}
require(type.enumValues.none { it.equals(UndefinedEnumEntryName, ignoreCase = true) }) {
"Cannot synthesize the '$UndefinedEnumEntryName' value for experimental enum " +
"${names.declaredName} (of domain ${names.domain.domainName}) because it clashes with an " +
"existing value (case-insensitive). Values:\n - ${type.enumValues.joinToString("\n - ")}"
}
addType(notInProtocolClassTypeSpec(serializerClass))
}.build()

private fun DomainTypeDeclaration.notInProtocolClassTypeSpec(serializerClass: ClassName) =
TypeSpec.classBuilder(UndefinedEnumEntryName).apply {
addModifiers(KModifier.DATA)
addSuperinterface(names.className)

// For calls to serializers made directly with this sub-object instead of the FC enum's interface.
// Example: Json.encodeToString(AXPropertyName.NotDefinedInProtocol("notRendered"))
// (and not Json.encodeToString<AXPropertyName>(AXPropertyName.NotDefinedInProtocol("notRendered"))
addAnnotation(Annotations.serializableWith(serializerClass))

addKdoc(
"This extra enum entry represents values returned by Chrome that were not defined in " +
"the protocol (for instance new values that were added later)."
)
primaryConstructor(FunSpec.constructorBuilder().apply {
addParameter("value", String::class)
}.build())
addProperty(PropertySpec.builder("value", String::class).initializer("value").build())
}.build()

private fun serializerForFCEnum(fcEnumClass: ClassName, enumValues: List<String>): TypeSpec =
TypeSpec.objectBuilder("${fcEnumClass.simpleName}Serializer").apply {
addModifiers(KModifier.PRIVATE)
superclass(ExtDeclarations.fcEnumSerializer.parameterizedBy(fcEnumClass))
addSuperclassConstructorParameter("%T::class", fcEnumClass)

addFunction(FunSpec.builder("fromCode").apply {
addModifiers(KModifier.OVERRIDE)
addParameter("code", String::class)
returns(fcEnumClass)
beginControlFlow("return when (code)")
enumValues.forEach {
addCode("%S -> %T\n", it, fcEnumClass.nestedClass(protocolEnumEntryNameToKotlinName(it)))
}
addCode("else -> %T(code)", fcEnumClass.nestedClass(UndefinedEnumEntryName))
endControlFlow()
}.build())

addFunction(FunSpec.builder("codeOf").apply {
addModifiers(KModifier.OVERRIDE)
addParameter("value", fcEnumClass)
returns(String::class)
beginControlFlow("return when (value)")
enumValues.forEach {
addCode("is %T -> %S\n", fcEnumClass.nestedClass(protocolEnumEntryNameToKotlinName(it)), it)
}
addCode("is %T -> value.value", fcEnumClass.nestedClass(UndefinedEnumEntryName))
endControlFlow()
}.build())
}.build()

private fun protocolEnumEntryNameToKotlinName(protocolName: String) = protocolName.dashesToCamelCase()

private fun String.dashesToCamelCase(): String = replace(Regex("""-(\w)""")) { it.groupValues[1].uppercase() }

private fun DomainTypeDeclaration.toTypeAliasSpec(type: ChromeDPType.NamedRef): TypeAliasSpec =
TypeAliasSpec.builder(names.declaredName, type.typeName).apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.MemberName
import com.squareup.kotlinpoet.MemberName.Companion.member
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KeepGeneratedSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.hildan.chrome.devtools.protocol.json.*

Expand All @@ -24,11 +27,13 @@ object ExtDeclarations {

val experimentalChromeApi = ClassName(protocolPackage, "ExperimentalChromeApi")

val fcEnumSerializer = ClassName(protocolPackage, "FCEnumSerializer")

val allDomainsTargetInterface = ClassName(targetsPackage, "AllDomainsTarget")
val allDomainsTargetImplementation = ClassName(targetsPackage, "UberTarget")

val sessionsFileName = "ChildSessions"
val sessionAdaptersFileName = "ChildSessionAdapters"
const val sessionsFileName = "ChildSessions"
const val sessionAdaptersFileName = "ChildSessionAdapters"
val childSessionInterface = ClassName(sessionsPackage, "ChildSession")
val childSessionUnsafeFun = childSessionInterface.member("unsafe")

Expand All @@ -41,6 +46,12 @@ object Annotations {

val serializable = AnnotationSpec.builder(Serializable::class).build()

fun serialName(name: String) = AnnotationSpec.builder(SerialName::class).addMember("%S", name).build()

fun serializableWith(serializerClass: ClassName) = AnnotationSpec.builder(Serializable::class)
.addMember("with = %T::class", serializerClass)
.build()

val jvmOverloads = AnnotationSpec.builder(JvmOverloads::class).build()

val deprecatedChromeApi = AnnotationSpec.builder(Deprecated::class)
Expand All @@ -63,6 +74,12 @@ object Annotations {
// annotating the relevant property/constructor-arg with experimental annotation. The whole class/constructor
// would need to be annotated as experimental, which is not desirable
"OPT_IN_USAGE",
// we add @SerializableWith on each sub-object in forward-compatible enum interfaces to avoid issues when using
// the serializers of the subtypes directly. The serialization plugin complains because we're using the parent
// interface serializer on each subtype instead of a KSerializer<Subtype>, which is technically not safe in
// general. We accept the tradeoff in our case, which is that this will throw ClassCaseException:
// val value: AXPropertyName.level = Json.decodeFromString<AXPropertyName.level>("\"url\"")
"SERIALIZER_TYPE_INCOMPATIBLE",
)

@Suppress("SameParameterValue")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ package org.hildan.chrome.devtools.protocol.names

import com.squareup.kotlinpoet.ClassName

/**
* The name of the synthetic enum entry used to represent deserialized values that are not defined in the protocol.
*/
// Note: 'unknown' and 'undefined' already exist in some of the enums, so we want to keep this one different
const val UndefinedEnumEntryName = "NotDefinedInProtocol"

@JvmInline
value class DomainNaming(
val domainName: String,
Expand Down Expand Up @@ -48,7 +54,10 @@ sealed class NamingConvention
data class DomainTypeNaming(
val declaredName: String,
val domain: DomainNaming,
) : NamingConvention()
) : NamingConvention() {
val packageName = domain.packageName
val className = ClassName(packageName, declaredName)
}

data class CommandNaming(
val commandName: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.hildan.chrome.devtools.protocol

import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlin.reflect.*

abstract class FCEnumSerializer<FC : Any>(fcClass: KClass<FC>) : KSerializer<FC> {

override val descriptor = PrimitiveSerialDescriptor(
serialName = fcClass.simpleName ?: error("Cannot create serializer for anonymous class"),
kind = PrimitiveKind.STRING,
)

override fun deserialize(decoder: Decoder): FC = fromCode(decoder.decodeString())

override fun serialize(encoder: Encoder, value: FC) {
encoder.encodeString(codeOf(value))
}

abstract fun fromCode(code: String): FC
abstract fun codeOf(value: FC): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.hildan.chrome.devtools.protocol

import kotlinx.serialization.*
import kotlinx.serialization.json.*
import org.hildan.chrome.devtools.domains.accessibility.*
import org.hildan.chrome.devtools.domains.bluetoothemulation.*
import kotlin.test.*

class FCEnumSerializerTest {

@Test
fun deserializesKnownValues() {
assertEquals(AXPropertyName.url, Json.decodeFromString<AXPropertyName>("\"url\""))
assertEquals(AXPropertyName.level, Json.decodeFromString<AXPropertyName>("\"level\""))
assertEquals(AXPropertyName.hiddenRoot, Json.decodeFromString<AXPropertyName>("\"hiddenRoot\""))
}

@Test
fun deserializesKnownValues_withDashes() {
assertEquals(CentralState.poweredOn, Json.decodeFromString<CentralState>("\"powered-on\""))
assertEquals(CentralState.poweredOff, Json.decodeFromString<CentralState>("\"powered-off\""))
}

@Test
fun deserializesUnknownValues() {
assertEquals(AXPropertyName.NotDefinedInProtocol("notRendered"), Json.decodeFromString<AXPropertyName>("\"notRendered\""))
assertEquals(AXPropertyName.NotDefinedInProtocol("uninteresting"), Json.decodeFromString<AXPropertyName>("\"uninteresting\""))
}

@Test
fun serializesKnownValues() {
assertEquals("\"url\"", Json.encodeToString(AXPropertyName.url))
assertEquals("\"level\"", Json.encodeToString(AXPropertyName.level))
assertEquals("\"hiddenRoot\"", Json.encodeToString(AXPropertyName.hiddenRoot))
}

@Test
fun serializesKnownValues_withDashes() {
assertEquals("\"powered-on\"", Json.encodeToString(CentralState.poweredOn))
assertEquals("\"powered-off\"", Json.encodeToString(CentralState.poweredOff))
}

@Test
fun serializesUnknownValues() {
assertEquals("\"notRendered\"", Json.encodeToString<AXPropertyName>(AXPropertyName.NotDefinedInProtocol("notRendered")))
assertEquals("\"uninteresting\"", Json.encodeToString(AXPropertyName.NotDefinedInProtocol("uninteresting")))
assertEquals("\"totallyInexistentStuff\"", Json.encodeToString(AXPropertyName.NotDefinedInProtocol("totallyInexistentStuff")))
}
}
24 changes: 24 additions & 0 deletions src/jvmTest/kotlin/IntegrationTests.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import org.hildan.chrome.devtools.domains.accessibility.AXProperty
import org.hildan.chrome.devtools.domains.accessibility.AXPropertyName
import org.hildan.chrome.devtools.domains.backgroundservice.ServiceName
import org.hildan.chrome.devtools.domains.dom.*
import org.hildan.chrome.devtools.domains.domdebugger.DOMBreakpointType
Expand Down Expand Up @@ -132,6 +134,28 @@ class IntegrationTests {
}
}

@OptIn(ExperimentalChromeApi::class)
@Test
fun test_deserialization_unknown_enum() {
runBlockingWithTimeout {
chromeDpClient().webSocket().use { browser ->
browser.newPage().use { page ->
page.goto("http://www.google.com")
val tree = page.accessibility.getFullAXTree() // just test that this doesn't fail

assertTrue("we are no longer testing that unknown AXPropertyName values are deserialized as NotDefinedInProtocol") {
tree.nodes.any { n ->
n.properties.anyUndefinedName() || n.ignoredReasons.anyUndefinedName()
}
}
}
}
}
}

private fun List<AXProperty>?.anyUndefinedName(): Boolean =
this != null && this.any { it.name is AXPropertyName.NotDefinedInProtocol }

@OptIn(ExperimentalChromeApi::class)
@Test
fun test_parallelPages() {
Expand Down
Loading