├── protocol-definition ├── version.txt ├── target_types.json └── README.md ├── .gitignore ├── protocol-generator ├── build.gradle.kts ├── cdp-json-parser │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── org │ │ └── hildan │ │ └── chrome │ │ └── devtools │ │ └── protocol │ │ └── json │ │ ├── ChromeJsonRepository.kt │ │ ├── TargetType.kt │ │ └── JsonProtocolTypes.kt ├── cdp-kotlin-generator │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── kotlin │ │ └── org │ │ └── hildan │ │ └── chrome │ │ └── devtools │ │ └── protocol │ │ ├── generator │ │ ├── ParametersGenerator.kt │ │ ├── Utils.kt │ │ ├── EventTypesGenerator.kt │ │ ├── Generator.kt │ │ ├── SessionGenerator.kt │ │ ├── DomainGenerator.kt │ │ ├── TargetGenerator.kt │ │ ├── CommandGenerator.kt │ │ └── DomainTypesGenerator.kt │ │ ├── names │ │ ├── Names.kt │ │ └── ExternalDeclarations.kt │ │ ├── preprocessing │ │ └── JsonPreProcessor.kt │ │ └── model │ │ └── Model.kt └── settings.gradle.kts ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── src ├── jvmTest │ ├── resources │ │ ├── child.html │ │ ├── page.html │ │ ├── basic.html │ │ ├── page-with-cross-origin-iframe.html │ │ ├── other.html │ │ ├── select.html │ │ └── trustedtype.html │ └── kotlin │ │ ├── BrowserlessRemoteIntegrationTests.kt │ │ ├── BrowserlessLocalIntegrationTests.kt │ │ ├── TestServer.kt │ │ ├── ManualTests.kt │ │ ├── ZenikaIntegrationTests.kt │ │ ├── LocalIntegrationTestBase.kt │ │ └── IntegrationTestBase.kt ├── commonMain │ └── kotlin │ │ └── org │ │ └── hildan │ │ └── chrome │ │ └── devtools │ │ ├── domains │ │ ├── page │ │ │ └── PageExtensions.kt │ │ ├── utils │ │ │ └── Conversions.kt │ │ ├── target │ │ │ └── TargetExtensions.kt │ │ ├── runtime │ │ │ └── RuntimeExtensions.kt │ │ ├── input │ │ │ └── InputExtensions.kt │ │ └── dom │ │ │ └── DOMExtensions.kt │ │ ├── sessions │ │ ├── SessionExtensions.kt │ │ ├── NewPageExtension.kt │ │ ├── InternalSessions.kt │ │ ├── GotoExtension.kt │ │ └── Sessions.kt │ │ ├── protocol │ │ ├── ExperimentalChromeApi.kt │ │ ├── FCEnumSerializer.kt │ │ ├── SessionSerialization.kt │ │ ├── ChromeDPSession.kt │ │ ├── ChromeDPFrames.kt │ │ ├── ChromeDPConnection.kt │ │ └── ChromeDPHttpApi.kt │ │ ├── targets │ │ └── TargetTypeNames.kt │ │ ├── extensions │ │ └── CrossDomainExtensions.kt │ │ ├── KtorClientExtensions.kt │ │ └── ChromeDP.kt ├── commonTest │ └── kotlin │ │ └── org │ │ └── hildan │ │ └── chrome │ │ └── devtools │ │ ├── domains │ │ ├── utils │ │ │ └── DOMExtensionsKtTest.kt │ │ └── dom │ │ │ └── DOMAttributesKtTest.kt │ │ ├── RealTimeTestScope.kt │ │ └── protocol │ │ └── FCEnumSerializerTest.kt └── jvmMain │ └── kotlin │ └── org │ └── hildan │ └── chrome │ └── devtools │ └── domains │ └── page │ └── PageExtensionsJvm.kt ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 4-dependency_upgrade.yml │ ├── 3-documentation_issue.yml │ ├── 2-feature_request.yml │ └── 1-bug_report.yml ├── workflows │ ├── test-chrome.yml │ ├── update-gradlew.yml │ ├── build.yml │ ├── update-protocol.yml │ └── release.yml └── dependabot.yml ├── kotlin-js-store └── wasm │ └── yarn.lock ├── gradle.properties ├── settings.gradle.kts ├── LICENSE └── gradlew.bat /protocol-definition/version.txt: -------------------------------------------------------------------------------- 1 | 1558402 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | .gradle 4 | .kotlin 5 | build 6 | 7 | src/commonMain/generated 8 | -------------------------------------------------------------------------------- /protocol-generator/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.jvm) apply false 3 | } 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joffrey-bion/chrome-devtools-kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/jvmTest/resources/child.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Child page

4 |

This is the child page

5 | 6 | 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.sh text eol=lf 4 | *.bat text eol=crlf 5 | 6 | # The binary-compatibility-validator generates LF line endings on all platforms 7 | *.api text eol=lf 8 | -------------------------------------------------------------------------------- /src/jvmTest/resources/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Test page

4 |

This is a test page with a link opening a new tab

5 | Go to child page 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/jvmTest/resources/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Basic tab title 4 | 5 | 6 |

Basic page title

7 |

8 | This is a simple paragraph 9 |

10 | 11 | 12 | -------------------------------------------------------------------------------- /src/jvmTest/resources/page-with-cross-origin-iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Test page

4 |

This is a test page with a cross-origin iframe

5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a question 4 | about: For general usage questions, please use GitHub Discussions 5 | url: https://github.com/joffrey-bion/chrome-devtools-kotlin/discussions 6 | -------------------------------------------------------------------------------- /protocol-generator/cdp-json-parser/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | alias(libs.plugins.kotlin.serialization) 4 | } 5 | 6 | group = "org.hildan.chrome" 7 | 8 | kotlin { 9 | jvmToolchain(11) 10 | } 11 | 12 | dependencies { 13 | implementation(libs.kotlinx.serialization.json) 14 | } 15 | -------------------------------------------------------------------------------- /src/jvmTest/resources/other.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Other tab title 4 | 5 | 6 |

Other page title

7 |

8 | This is a simple paragraph 9 |

10 |

11 | This is a second paragraph 12 |

13 | 14 | 15 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | } 4 | 5 | group = "org.hildan.chrome" 6 | 7 | kotlin { 8 | jvmToolchain(11) 9 | } 10 | 11 | dependencies { 12 | implementation(project(":cdp-json-parser")) 13 | implementation(libs.kotlinpoet) 14 | implementation(libs.kotlinx.serialization.json) 15 | } 16 | -------------------------------------------------------------------------------- /kotlin-js-store/wasm/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ws@8.18.3: 6 | version "8.18.3" 7 | resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" 8 | integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Extra Gradle memory required for Dokka plugin 2 | org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 3 | 4 | org.gradle.caching=true 5 | org.gradle.configuration-cache=true 6 | 7 | kotlin.native.ignoreDisabledTargets=true 8 | 9 | org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled 10 | org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true 11 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/domains/page/PageExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.page 2 | 3 | import kotlin.io.encoding.Base64 4 | import kotlin.io.encoding.ExperimentalEncodingApi 5 | 6 | /** 7 | * Decodes the base64 data of the screenshot into image bytes. 8 | */ 9 | @OptIn(ExperimentalEncodingApi::class) 10 | fun CaptureScreenshotResponse.decodeData(): ByteArray = Base64.decode(data) 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4-dependency_upgrade.yml: -------------------------------------------------------------------------------- 1 | name: Dependency upgrade 2 | description: Upgrade a dependency 3 | title: Upgrade ARTIFACT_NAME to version VERSION 4 | labels: ["dependencies"] 5 | body: 6 | - type: textarea 7 | id: additional-info 8 | attributes: 9 | label: Additional information 10 | description: If this version upgrade is necessary for a specific reason, please describe why. 11 | validations: 12 | required: false 13 | -------------------------------------------------------------------------------- /.github/workflows/test-chrome.yml: -------------------------------------------------------------------------------- 1 | name: test-chrome 2 | 3 | on: [workflow_dispatch] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ ubuntu-latest, macos-latest, windows-latest ] 11 | steps: 12 | - id: setup-chrome 13 | uses: browser-actions/setup-chrome@v2 14 | - name: Run chrome 15 | run: | 16 | ${{ steps.setup-chrome.outputs.chrome-path }} --version 17 | -------------------------------------------------------------------------------- /protocol-generator/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0") 3 | } 4 | 5 | rootProject.name = "protocol-generator" 6 | 7 | dependencyResolutionManagement { 8 | repositories { 9 | mavenCentral() 10 | } 11 | versionCatalogs { 12 | create("libs") { 13 | from(files("../gradle/libs.versions.toml")) 14 | } 15 | } 16 | } 17 | 18 | include(":cdp-kotlin-generator") 19 | include(":cdp-json-parser") 20 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.gradle.develocity") version "4.3" 3 | id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0") 4 | } 5 | 6 | rootProject.name = "chrome-devtools-kotlin" 7 | 8 | develocity { 9 | buildScan { 10 | termsOfUseUrl = "https://gradle.com/terms-of-service" 11 | termsOfUseAgree = "yes" 12 | uploadInBackground = false // bad for CI, and not critical for local runs 13 | } 14 | } 15 | 16 | includeBuild("protocol-generator") 17 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/sessions/SessionExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.sessions 2 | 3 | import org.hildan.chrome.devtools.domains.target.* 4 | import org.hildan.chrome.devtools.targets.* 5 | 6 | /** 7 | * Finds page targets that were opened by this page. 8 | */ 9 | suspend fun PageSession.childPages(): List { 10 | val thisTargetId = metaData.targetId 11 | return target.getTargets().targetInfos.filter { it.type == TargetTypeNames.page && it.openerId == thisTargetId } 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-documentation_issue.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | description: Report a problem with the documentation 3 | labels: [ "documentation" ] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Thanks for taking the time to open an issue! 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: Problem description 12 | description: | 13 | Please describe the problem as concisely as possible. 14 | If it's a clear typo, don't hesitate to open a PR to fix it instead of opening an issue. 15 | validations: 16 | required: true 17 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/BrowserlessRemoteIntegrationTests.kt: -------------------------------------------------------------------------------- 1 | import org.junit.jupiter.api.Disabled 2 | 3 | @Disabled("Getting HTTP 429 even before reaching the quota") 4 | class BrowserlessRemoteIntegrationTests : IntegrationTestBase() { 5 | 6 | private val token = System.getenv("BROWSERLESS_API_TOKEN") 7 | ?: error("BROWSERLESS_API_TOKEN environment variable is missing") 8 | 9 | override val httpUrl: String 10 | get() = "https://production-lon.browserless.io?token=${token}" 11 | 12 | override val wsConnectUrl: String 13 | get() = "wss://production-lon.browserless.io?token=${token}" 14 | } 15 | -------------------------------------------------------------------------------- /src/jvmTest/resources/select.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 13 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/protocol/ExperimentalChromeApi.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | /** 4 | * This annotation is used on DevTools Protocol APIs and types that are marked as experimental in the protocol itself. 5 | */ 6 | @RequiresOptIn(level = RequiresOptIn.Level.WARNING) 7 | @Target( 8 | AnnotationTarget.CLASS, 9 | AnnotationTarget.FIELD, 10 | AnnotationTarget.FUNCTION, 11 | AnnotationTarget.PROPERTY, 12 | AnnotationTarget.TYPEALIAS 13 | ) 14 | // This is used in generated code by fully qualified name, be careful when renaming or moving to another package 15 | annotation class ExperimentalChromeApi 16 | -------------------------------------------------------------------------------- /.github/workflows/update-gradlew.yml: -------------------------------------------------------------------------------- 1 | name: update-gradlew 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # Every night at 5am 7 | - cron: "0 5 * * *" 8 | 9 | jobs: 10 | update-gradlew: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v6 15 | 16 | - name: Set up JDK 17 | uses: actions/setup-java@v5 18 | with: 19 | distribution: 'temurin' 20 | java-version: 21 21 | 22 | - name: Update Gradle wrapper 23 | uses: gradle-update/update-gradle-wrapper-action@v2 24 | with: 25 | repo-token: ${{ secrets.GH_PAT }} 26 | labels: dependencies, internal 27 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/org/hildan/chrome/devtools/domains/utils/DOMExtensionsKtTest.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.utils 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class DOMExtensionsKtTest { 7 | 8 | @Test 9 | fun toViewport() { 10 | val quad = listOf( 11 | 1.0, 2.0, // top left 12 | 4.0, 2.0, // top right 13 | 4.0, 3.0, // bottom right 14 | 1.0, 3.0, // bottom left 15 | ) 16 | val viewport = quad.toViewport() 17 | assertEquals(1.0, viewport.x) 18 | assertEquals(2.0, viewport.y) 19 | assertEquals(1.0, viewport.height) 20 | assertEquals(3.0, viewport.width) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/jvmMain/kotlin/org/hildan/chrome/devtools/domains/page/PageExtensionsJvm.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.page 2 | 3 | import java.nio.file.Path 4 | import kotlin.io.path.writeBytes 5 | 6 | /** 7 | * Captures a screenshot of the current page based on the given [options], and stores the resulting image into a new 8 | * file at the given [outputImagePath]. If the file already exists, it is overwritten. 9 | */ 10 | suspend fun PageDomain.captureScreenshotToFile( 11 | outputImagePath: Path, 12 | options: CaptureScreenshotRequest.Builder.() -> Unit = {}, 13 | ) { 14 | val capture = captureScreenshot(options) 15 | val imageBytes = capture.decodeData() 16 | outputImagePath.writeBytes(imageBytes) 17 | } 18 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/targets/TargetTypeNames.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.targets 2 | 3 | /** 4 | * The name of the possible target types, as defined in 5 | * [Chromium's code](https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc;l=66-75). 6 | */ 7 | internal object TargetTypeNames { 8 | const val tab = "tab" 9 | const val page = "page" 10 | const val iFrame = "iframe" 11 | const val worker = "worker" 12 | const val sharedWorker = "shared_worker" 13 | const val serviceWorker = "service_worker" 14 | const val browser = "browser" 15 | const val webview = "webview" 16 | const val other = "other" 17 | const val auctionWorklet = "auction_worklet" 18 | } 19 | -------------------------------------------------------------------------------- /src/jvmTest/resources/trustedtype.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test case for trustedtype in RemoteObject.subtype 4 | 5 | 6 |

7 | This page should log something to the console. This is a reproducer for 8 | this GitHub issue. 9 |

10 | 21 | 22 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/domains/utils/Conversions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.utils 2 | 3 | import org.hildan.chrome.devtools.domains.dom.Quad 4 | import org.hildan.chrome.devtools.domains.page.Viewport 5 | 6 | /** 7 | * Converts this [Quad] into a [Viewport] for screenshot captures. 8 | */ 9 | fun Quad.toViewport(): Viewport = Viewport( 10 | x = this[0], 11 | y = this[1], 12 | width = this[4] - this[0], 13 | height = this[5] - this[1], 14 | scale = 1.0, 15 | ) 16 | 17 | internal data class Point(val x: Double, val y: Double) 18 | 19 | internal val Quad.center: Point 20 | get() = Point( 21 | x = middle(start = this[4], end = this[0]), 22 | y = middle(start = this[5], end = this[1]), 23 | ) 24 | 25 | private fun middle(start: Double, end: Double) = start + (end - start) / 2 26 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/protocol/FCEnumSerializer.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.descriptors.* 5 | import kotlinx.serialization.encoding.* 6 | import kotlin.reflect.* 7 | 8 | /** 9 | * A serializer for forward-compatible enum types. 10 | */ 11 | abstract class FCEnumSerializer(fcClass: KClass) : KSerializer { 12 | 13 | override val descriptor = PrimitiveSerialDescriptor( 14 | serialName = fcClass.simpleName ?: error("Cannot create serializer for anonymous class"), 15 | kind = PrimitiveKind.STRING, 16 | ) 17 | 18 | override fun deserialize(decoder: Decoder): FC = fromCode(decoder.decodeString()) 19 | 20 | override fun serialize(encoder: Encoder, value: FC) { 21 | encoder.encodeString(codeOf(value)) 22 | } 23 | 24 | abstract fun fromCode(code: String): FC 25 | abstract fun codeOf(value: FC): String 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joffrey Bion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /protocol-generator/cdp-json-parser/src/main/kotlin/org/hildan/chrome/devtools/protocol/json/ChromeJsonRepository.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.json 2 | 3 | import java.net.URL 4 | 5 | object ChromeJsonRepository { 6 | 7 | private const val rawFilesBaseUrl = "https://raw.githubusercontent.com/ChromeDevTools/devtools-protocol" 8 | 9 | fun descriptorUrls(revision: String) = JsonDescriptors( 10 | browserProtocolUrl = rawContentUrl(revision, "json/browser_protocol.json"), 11 | jsProtocolUrl = rawContentUrl(revision, "json/js_protocol.json"), 12 | ) 13 | 14 | fun fetchProtocolNpmVersion(revision: String): String { 15 | val packageJson = rawContentUrl(revision, "package.json").readText() 16 | return Regex(""""version"\s*:\s*"([^"]+)"""").find(packageJson)?.groupValues?.get(1) ?: "not-found" 17 | } 18 | 19 | private fun rawContentUrl(revision: String, pathInRepo: String) = URL("$rawFilesBaseUrl/$revision/$pathInRepo") 20 | } 21 | 22 | data class JsonDescriptors( 23 | val browserProtocolUrl: URL, 24 | val jsProtocolUrl: URL, 25 | ) { 26 | val all = listOf(browserProtocolUrl, jsProtocolUrl) 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request or enhancement 2 | description: Suggest an idea for this project, be it a big feature or a small UX improvement 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Thanks for taking the time to suggest ideas! 8 | - type: textarea 9 | id: problem 10 | attributes: 11 | label: Problem / use case 12 | placeholder: Describe your problem or use-case 13 | description: Why do we need this? Don't hesitate to describe what you're trying to achieve on a larger scale. 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: solution 18 | attributes: 19 | label: Feature (solution) 20 | placeholder: Describe the solution you'd like 21 | description: How would it look like? 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: alternatives 26 | attributes: 27 | label: Current alternatives 28 | placeholder: Describe alternatives/workarounds you've considered 29 | description: Is there a way -maybe inconvenient- to achieve your goal at the moment, or a workaround? 30 | validations: 31 | required: false 32 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/org/hildan/chrome/devtools/RealTimeTestScope.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools 2 | 3 | import kotlinx.coroutines.CoroutineExceptionHandler 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.plus 7 | import kotlinx.coroutines.test.runTest 8 | import kotlinx.coroutines.withContext 9 | import kotlin.coroutines.CoroutineContext 10 | import kotlin.time.Duration.Companion.minutes 11 | 12 | interface RealTimeTestScope : CoroutineScope { 13 | val backgroundScope: CoroutineScope 14 | } 15 | 16 | /** 17 | * Provides the same facilities as [runTest] but without delay skipping. 18 | */ 19 | fun runTestWithRealTime( 20 | nestedContext: CoroutineContext = Dispatchers.Default, 21 | block: suspend RealTimeTestScope.() -> Unit, 22 | ) = runTest(timeout = 3.minutes) { // increased timeout for local server setup 23 | val testScopeBackground = backgroundScope + CoroutineExceptionHandler { _, e -> 24 | println("Error in background scope: $e") 25 | } 26 | withContext(nestedContext) { 27 | val regularScope = this 28 | val scopeWithBackground = object : RealTimeTestScope, CoroutineScope by regularScope { 29 | override val backgroundScope: CoroutineScope = testScopeBackground 30 | } 31 | scopeWithBackground.block() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Thanks for taking the time to fill out this bug report! 8 | - type: input 9 | id: version 10 | attributes: 11 | label: Version 12 | description: What version of Chrome DevTools Kotlin are you running? 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: what-happened 17 | attributes: 18 | label: What happened? 19 | placeholder: Incorrect behaviour, error message, stacktrace... 20 | description: Please describe what you see and what you expected to happen instead 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: reproduction 25 | attributes: 26 | label: Reproduction and additional details 27 | placeholder: Snippet of code, contextual information... 28 | description: Please provide information that could help reproduce the problem. 29 | validations: 30 | required: false 31 | - type: input 32 | id: platforms 33 | attributes: 34 | label: Kotlin target platforms 35 | description: The Kotlin target platform(s) where you noticed the bug - Android, JVM (non-Android), JS (browser), JS (node), Native (which one?), etc. 36 | validations: 37 | required: false 38 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/domains/target/TargetExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.target 2 | 3 | import kotlinx.coroutines.flow.* 4 | import org.hildan.chrome.devtools.domains.target.events.* 5 | import org.hildan.chrome.devtools.protocol.* 6 | 7 | /** 8 | * When collected, this flow enables target discovery and aggregates single-target events to provide updates to a 9 | * complete map of targets by ID. 10 | */ 11 | suspend fun TargetDomain.allTargetsFlow(): Flow> = events() 12 | .scan(emptyMap()) { targets, event -> targets.updatedBy(event) } 13 | .onStart { 14 | // triggers target info events, including creation events for existing targets 15 | setDiscoverTargets(discover = true) 16 | } 17 | 18 | @OptIn(ExperimentalChromeApi::class) 19 | private fun Map.updatedBy(event: TargetEvent): Map = when (event) { 20 | is TargetEvent.TargetCreated -> this + (event.targetInfo.targetId to event.targetInfo) 21 | is TargetEvent.TargetInfoChanged -> this + (event.targetInfo.targetId to event.targetInfo) 22 | is TargetEvent.TargetDestroyed -> this - event.targetId 23 | is TargetEvent.TargetCrashed -> this - event.targetId 24 | is TargetEvent.AttachedToTarget, // 25 | is TargetEvent.DetachedFromTarget, // 26 | is TargetEvent.ReceivedMessageFromTarget -> this // irrelevant events 27 | } 28 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/org/hildan/chrome/devtools/domains/dom/DOMAttributesKtTest.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.dom 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertNull 6 | 7 | class DOMAttributesKtTest { 8 | 9 | @Test 10 | fun basicStringAttributes() { 11 | val attrsList = listOf("href", "http://www.google.com", "title", "My Tooltip") 12 | val attrs = attrsList.asDOMAttributes() 13 | assertEquals("http://www.google.com", attrs.href) 14 | assertEquals("My Tooltip", attrs.title) 15 | } 16 | 17 | @Test 18 | fun missingAttributes() { 19 | val attrsList = listOf("title", "My Tooltip") 20 | val attrs = attrsList.asDOMAttributes() 21 | assertNull(attrs.href) 22 | assertNull(attrs.`class`) 23 | assertEquals("My Tooltip", attrs.title) 24 | } 25 | 26 | @Test 27 | fun convertedAttributes() { 28 | val attrsList = listOf("width", "150", "selected", "false", "height", "320") 29 | val attrs = attrsList.asDOMAttributes() 30 | assertEquals(false, attrs.selected) 31 | assertEquals(150, attrs.width) 32 | assertEquals(320, attrs.height) 33 | } 34 | 35 | @Test 36 | fun attributesWithoutValue() { 37 | val attrsList = listOf("selected", "", "title", "Bob") 38 | val attrs = attrsList.asDOMAttributes() 39 | assertEquals(true, attrs.selected) 40 | assertEquals("Bob", attrs.title) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/BrowserlessLocalIntegrationTests.kt: -------------------------------------------------------------------------------- 1 | import org.junit.jupiter.api.Disabled 2 | import org.testcontainers.containers.* 3 | import org.testcontainers.junit.jupiter.* 4 | import org.testcontainers.junit.jupiter.Container 5 | import java.time.* 6 | 7 | @Testcontainers 8 | class BrowserlessLocalIntegrationTests : LocalIntegrationTestBase() { 9 | 10 | /** 11 | * A container running Browserless with Chromium support. 12 | * It is meant to be used mostly with the web socket API, which is accessible directly at `ws://localhost:{port}` 13 | * (no need for an intermediate HTTP call). 14 | * 15 | * It provides a bridge to the HTTP endpoints `/json/protocol` and `/json/version` of the DevTools protocol as well, 16 | * but not for endpoints related to tab manipulation. 17 | * The `/json/list` and `/json/new` endpoints are just mocked to allow some clients to get the WS URL, but they 18 | * don't actually reflect the reality or affect the browser's state. 19 | * See [Browser REST APIs](https://docs.browserless.io/open-api#tag/Browser-REST-APIs) in the docs. 20 | */ 21 | @Container 22 | var browserlessChromium: GenericContainer<*> = GenericContainer("ghcr.io/browserless/chromium:latest") 23 | .withStartupTimeout(Duration.ofMinutes(5)) // sometimes more than the default 2 minutes on CI 24 | .withExposedPorts(3000) 25 | .withAccessToHost(true) 26 | 27 | override val httpUrl: String 28 | get() = "http://${browserlessChromium.host}:${browserlessChromium.getMappedPort(3000)}" 29 | 30 | override val wsConnectUrl: String 31 | get() = "ws://${browserlessChromium.host}:${browserlessChromium.getMappedPort(3000)}" 32 | } 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | labels: 9 | - dependencies 10 | - internal 11 | 12 | - package-ecosystem: "gradle" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | labels: 17 | - dependencies 18 | ignore: 19 | - dependency-name: "com.gradle.develocity" 20 | - dependency-name: "de.fayard.refreshVersions" 21 | - dependency-name: "io.github.gradle-nexus.publish-plugin" 22 | - dependency-name: "org.hildan.github.changelog" 23 | - dependency-name: "org.hildan.kotlin-publish" 24 | - dependency-name: "org.jetbrains.kotlinx.binary-compatibility-validator" 25 | - dependency-name: "org.jetbrains.dokka" 26 | - dependency-name: "org.slf4j:slf4j-simple" 27 | - dependency-name: "org.testcontainers:*" 28 | - dependency-name: "ru.vyarus.github-info" 29 | 30 | - package-ecosystem: "gradle" 31 | directory: "/" 32 | schedule: 33 | interval: "daily" 34 | labels: 35 | - dependencies 36 | - internal 37 | allow: 38 | - dependency-name: "com.gradle.develocity" 39 | - dependency-name: "de.fayard.refreshVersions" 40 | - dependency-name: "io.github.gradle-nexus.publish-plugin" 41 | - dependency-name: "org.hildan.github.changelog" 42 | - dependency-name: "org.hildan.kotlin-publish" 43 | - dependency-name: "org.jetbrains.kotlinx.binary-compatibility-validator" 44 | - dependency-name: "org.jetbrains.dokka" 45 | - dependency-name: "org.slf4j:slf4j-simple" 46 | - dependency-name: "org.testcontainers:*" 47 | - dependency-name: "ru.vyarus.github-info" 48 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-15-intel] # ARM runners don't support nested virtualization 11 | jdk-version: [21] 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v6 15 | 16 | # Docker is not installed on GitHub's MacOS hosted workers due to licensing issues 17 | - name: Setup docker (missing on MacOS) 18 | if: runner.os == 'macos' 19 | # colima is not installed in macos-15-intel, so we need this action now 20 | uses: douglascamata/setup-docker-macos-action@v1.0.2 21 | 22 | # For testcontainers to find the Colima socket 23 | # https://github.com/abiosoft/colima/blob/main/docs/FAQ.md#cannot-connect-to-the-docker-daemon-at-unixvarrundockersock-is-the-docker-daemon-running 24 | - name: Map docker.sock to colima (macOS only) 25 | if: runner.os == 'macos' 26 | run: | 27 | sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock 28 | 29 | - name: Set up JDK ${{ matrix.jdk-version }} 30 | uses: actions/setup-java@v5 31 | with: 32 | distribution: 'temurin' 33 | java-version: ${{ matrix.jdk-version }} 34 | 35 | - name: Cache Kotlin/Native binaries 36 | uses: actions/cache@v5 37 | with: 38 | path: ~/.konan 39 | key: konan-${{ runner.os }} 40 | 41 | - name: Set up Gradle 42 | uses: gradle/actions/setup-gradle@v5 43 | with: 44 | cache-encryption-key: ${{ secrets.GRADLE_CACHE_ENCRYPTION_KEY }} 45 | 46 | - name: Build with Gradle 47 | run: ./gradlew build --stacktrace 48 | env: 49 | BROWSERLESS_API_TOKEN: ${{ secrets.BROWSERLESS_API_TOKEN }} 50 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/domains/runtime/RuntimeExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.runtime 2 | 3 | import kotlinx.serialization.DeserializationStrategy 4 | import kotlinx.serialization.json.Json 5 | import kotlinx.serialization.serializer 6 | 7 | private val json = Json { ignoreUnknownKeys = true } 8 | 9 | /** 10 | * Evaluates the given [js] expression, and returns the result as a value of type [T]. 11 | * 12 | * The value is converted from JSON using Kotlinx Serialization, so the type [T] must be 13 | * [@Serializable][kotlinx.serialization.Serializable]. 14 | */ 15 | suspend inline fun RuntimeDomain.evaluateJs(js: String): T? = evaluateJs(js, serializer()) 16 | 17 | /** 18 | * Evaluates the given [js] expression, and returns the result as a value of type [T] using the provided [deserializer]. 19 | * 20 | * The value is converted from JSON using Kotlinx Serialization, so the type [T] must be 21 | * [@Serializable][kotlinx.serialization.Serializable]. 22 | */ 23 | suspend fun RuntimeDomain.evaluateJs(js: String, deserializer: DeserializationStrategy): T? { 24 | val response = evaluate(js) { returnByValue = true } 25 | if (response.exceptionDetails != null) { 26 | throw RuntimeJSEvaluationException(js, response.exceptionDetails) 27 | } 28 | if (response.result.value == null) { 29 | return null 30 | } 31 | return json.decodeFromJsonElement(deserializer, response.result.value) 32 | } 33 | 34 | /** 35 | * Thrown when the evaluation of some JS expression went wrong. 36 | */ 37 | class RuntimeJSEvaluationException( 38 | /** The expression that failed to evaluate. */ 39 | val jsExpression: String, 40 | /** The details of the evaluation error. */ 41 | val details: ExceptionDetails, 42 | ): Exception(details.exception?.description ?: details.stackTrace?.description ?: details.text) 43 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/ParametersGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.* 4 | import org.hildan.chrome.devtools.protocol.model.ChromeDPParameter 5 | 6 | internal fun TypeSpec.Builder.addPrimaryConstructorProps(props: List) { 7 | val parameterSpecs = props.map { 8 | it.toParameterSpec( 9 | // No need to add KDoc to the constructor param, adding it to the property is sufficient. 10 | // We don't add deprecated/experimental annotations here as they are already added on the property declaration. 11 | // Since both the property and the constructor arg are the same declaration, it would result in double 12 | // annotations. 13 | includeDocAndAnnotations = false, 14 | ) 15 | } 16 | val propertySpecs = props.map { 17 | it.toPropertySpec { 18 | initializer(it.name) // necessary to merge primary constructor arguments and properties 19 | } 20 | } 21 | primaryConstructor(FunSpec.constructorBuilder().addParameters(parameterSpecs).build()) 22 | addProperties(propertySpecs) 23 | } 24 | 25 | internal fun ChromeDPParameter.toParameterSpec(includeDocAndAnnotations: Boolean = true): ParameterSpec = 26 | ParameterSpec.builder(name, type).apply { 27 | if (includeDocAndAnnotations) { 28 | addKDocAndStabilityAnnotations(this@toParameterSpec) 29 | } 30 | 31 | if (type.isNullable) { 32 | defaultValue("null") 33 | } 34 | }.build() 35 | 36 | internal fun ChromeDPParameter.toPropertySpec(configure: PropertySpec.Builder.() -> Unit = {}): PropertySpec = 37 | PropertySpec.builder(name, type).apply { 38 | addKDocAndStabilityAnnotations(this@toPropertySpec) 39 | configure() 40 | }.build() 41 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/Utils.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.* 4 | import org.hildan.chrome.devtools.protocol.model.* 5 | import org.hildan.chrome.devtools.protocol.names.* 6 | 7 | fun T.addKDocAndStabilityAnnotations(element: ChromeDPElement) where T : Annotatable.Builder<*>, T : Documentable.Builder<*> { 8 | addKdoc(element.description?.escapeForKotlinPoet() ?: "*(undocumented in the protocol definition)*") 9 | element.docUrl?.let { 10 | addKdoc("\n\n[Official\u00A0doc]($it)".escapeForKotlinPoet()) 11 | } 12 | if (element.deprecated) { 13 | addAnnotation(Annotations.deprecatedChromeApi) 14 | } 15 | if (element.experimental) { 16 | addAnnotation(Annotations.experimentalChromeApi) 17 | } 18 | } 19 | 20 | // KotlinPoet interprets % signs as format elements, and we don't use this for docs generated from descriptions 21 | private fun String.escapeForKotlinPoet(): String = replace("%", "%%") 22 | 23 | internal data class ConstructorCallTemplate( 24 | val template: String, 25 | val namedArgsMapping: Map, 26 | ) 27 | 28 | internal fun constructorCallTemplate(constructedTypeName: TypeName, paramNames: List): ConstructorCallTemplate { 29 | val constructedTypeArgName = "cdk_constructedTypeName" 30 | val commandArgsTemplate = paramNames.joinToString(", ") { "%$it:L" } 31 | val commandArgsMapping = paramNames.associateWith { it } 32 | check(constructedTypeArgName !in commandArgsMapping) { 33 | "Unlucky state! An argument for constructor $constructedTypeName has exactly the name $constructedTypeArgName" 34 | } 35 | val argsMapping = mapOf(constructedTypeArgName to constructedTypeName) + commandArgsMapping 36 | return ConstructorCallTemplate( 37 | template = "%$constructedTypeArgName:T($commandArgsTemplate)", 38 | namedArgsMapping = argsMapping, 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/update-protocol.yml: -------------------------------------------------------------------------------- 1 | name: Daily protocol updates 2 | on: 3 | workflow_dispatch: { } # to allow manual trigger 4 | schedule: 5 | # Every night at 4am 6 | - cron: "0 4 * * *" 7 | 8 | jobs: 9 | update: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v6 14 | 15 | - name: Set up JDK 16 | uses: actions/setup-java@v5 17 | with: 18 | distribution: 'temurin' 19 | java-version: 21 20 | 21 | - name: Update protocol 22 | uses: gradle/gradle-build-action@v2 23 | with: 24 | arguments: updateProtocolDefinitions 25 | 26 | - name: Read protocol version 27 | id: protocol-version 28 | run: echo "version=$(cat protocol-definition/version.txt)" >> $GITHUB_OUTPUT 29 | 30 | - name: Update public ABI descriptors 31 | uses: gradle/gradle-build-action@v2 32 | with: 33 | arguments: apiDump 34 | 35 | - name: Create pull-request 36 | uses: peter-evans/create-pull-request@v8 37 | with: 38 | token: "${{ secrets.GH_PAT }}" # using personal token to trigger CI build 39 | branch: "protocol-update" 40 | author: "Protocol Update Workflow " 41 | committer: "Protocol Update Workflow " 42 | commit-message: "Update to Chrome DevTools Protocol version ${{ steps.protocol-version.outputs.version }}" 43 | title: "Update to Chrome DevTools Protocol version ${{ steps.protocol-version.outputs.version }}" 44 | labels: dependencies 45 | body: | 46 | This PR updates the protocol definitions (under `protocol-definition/`) to the latest version found in 47 | [the `devtools-protocol` github repo](https://github.com/ChromeDevTools/devtools-protocol/tree/master/json). 48 | 49 | The ABI descriptors (under `api/`) are also updated to reflect the changes in the generated code. 50 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/EventTypesGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.ClassName 4 | import com.squareup.kotlinpoet.FileSpec 5 | import com.squareup.kotlinpoet.KModifier 6 | import com.squareup.kotlinpoet.TypeSpec 7 | import org.hildan.chrome.devtools.protocol.model.ChromeDPDomain 8 | import org.hildan.chrome.devtools.protocol.model.ChromeDPEvent 9 | import org.hildan.chrome.devtools.protocol.names.Annotations 10 | 11 | fun ChromeDPDomain.createDomainEventTypesFileSpec(): FileSpec = 12 | FileSpec.builder(packageName = names.eventsPackageName, fileName = names.eventsFilename).apply { 13 | addAnnotation(Annotations.suppressWarnings) 14 | addType(createEventSealedClass()) 15 | }.build() 16 | 17 | private fun ChromeDPDomain.createEventSealedClass(): TypeSpec = 18 | TypeSpec.classBuilder(names.eventsParentClassName) 19 | .apply { 20 | addAnnotation(Annotations.serializable) 21 | addModifiers(KModifier.SEALED) 22 | events.forEach { 23 | addType(it.createEventSubTypeSpec(names.eventsParentClassName)) 24 | } 25 | }.build() 26 | 27 | private fun ChromeDPEvent.createEventSubTypeSpec(parentSealedClass: ClassName): TypeSpec = if (parameters.isEmpty()) { 28 | TypeSpec.objectBuilder(names.eventTypeName).apply { 29 | configureCommonSettings(this@createEventSubTypeSpec, parentSealedClass) 30 | }.build() 31 | } else { 32 | TypeSpec.classBuilder(names.eventTypeName).apply { 33 | configureCommonSettings(this@createEventSubTypeSpec, parentSealedClass) 34 | addModifiers(KModifier.DATA) 35 | addPrimaryConstructorProps(parameters) 36 | }.build() 37 | } 38 | 39 | private fun TypeSpec.Builder.configureCommonSettings(chromeDPEvent: ChromeDPEvent, parentSealedClass: ClassName) { 40 | addKDocAndStabilityAnnotations(chromeDPEvent) 41 | superclass(parentSealedClass) 42 | addAnnotation(Annotations.serializable) 43 | } 44 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/domains/input/InputExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.input 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlin.time.* 5 | import kotlin.time.Duration.Companion.milliseconds 6 | 7 | /** 8 | * Simulates a mouse click on the given [x] and [y] coordinates. 9 | * This uses a `mousePressed` and `mouseReleased` event in quick succession. 10 | * 11 | * The current tab doesn't need to be focused. 12 | * If this click opens a new tab, that new tab may become focused, but this session still targets the old tab. 13 | */ 14 | suspend fun InputDomain.dispatchMouseClick( 15 | x: Double, 16 | y: Double, 17 | clickDuration: Duration = 100.milliseconds, 18 | button: MouseButton = MouseButton.left, 19 | ) { 20 | dispatchMouseEvent( 21 | type = MouseEventType.mousePressed, 22 | x = x, 23 | y = y, 24 | ) { 25 | this.button = button 26 | this.clickCount = 1 27 | } 28 | delay(clickDuration) 29 | dispatchMouseEvent( 30 | type = MouseEventType.mouseReleased, 31 | x = x, 32 | y = y, 33 | ) { 34 | this.button = button 35 | } 36 | } 37 | 38 | /** 39 | * Simulates a mouse click on the given [x] and [y] coordinates. 40 | * This uses a `mousePressed` and `mouseReleased` event in quick succession. 41 | * 42 | * The current tab doesn't need to be focused. 43 | * If this click opens a new tab, that new tab may become focused, but this session still targets the old tab. 44 | */ 45 | @Deprecated( 46 | message = "Use dispatchMouseClick with a Duration type for clickDuration.", 47 | replaceWith = ReplaceWith( 48 | expression = "this.dispatchMouseClick(x, y, clickDurationMillis.milliseconds, button)", 49 | imports = ["kotlin.time.Duration.Companion.milliseconds"], 50 | ), 51 | level = DeprecationLevel.ERROR, 52 | ) 53 | suspend fun InputDomain.dispatchMouseClick( 54 | x: Double, 55 | y: Double, 56 | clickDurationMillis: Long, 57 | button: MouseButton = MouseButton.left, 58 | ) { 59 | dispatchMouseClick(x, y, clickDurationMillis.milliseconds, button) 60 | } 61 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/sessions/NewPageExtension.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.sessions 2 | 3 | import org.hildan.chrome.devtools.protocol.* 4 | 5 | /** 6 | * Creates a new page and attaches to it, then returns the new child [PageSession]. 7 | * The underlying web socket connection of this [BrowserSession] is reused for the new child [PageSession]. 8 | * 9 | * Use the [configure] parameter to further configure the properties of the new page. 10 | * 11 | * By default, the new page is created in a new isolated browser context (think of it as incognito mode). 12 | * This can be disabled via the [configure] parameter by setting [NewPageConfigBuilder.incognito] to false. 13 | */ 14 | @OptIn(ExperimentalChromeApi::class) 15 | suspend fun BrowserSession.newPage(configure: NewPageConfigBuilder.() -> Unit = {}): PageSession { 16 | val config = NewPageConfigBuilder().apply(configure) 17 | 18 | val browserContextId = when (config.incognito) { 19 | true -> target.createBrowserContext { disposeOnDetach = true }.browserContextId 20 | false -> null 21 | } 22 | 23 | val targetId = target.createTarget(url = "about:blank") { 24 | this.browserContextId = browserContextId 25 | width = config.width 26 | height = config.height 27 | newWindow = config.newWindow 28 | background = config.background 29 | }.targetId 30 | 31 | return attachToTarget(targetId).asPageSession() 32 | } 33 | 34 | /** 35 | * Defines properties that can be customized when creating a new page. 36 | */ 37 | class NewPageConfigBuilder internal constructor() { 38 | /** 39 | * If true, the new target is created in a separate browser context (think of it as incognito window). 40 | */ 41 | var incognito: Boolean = true 42 | 43 | /** 44 | * Frame width in DIP (headless chrome only). 45 | */ 46 | var width: Int? = null 47 | 48 | /** 49 | * Frame height in DIP (headless chrome only). 50 | */ 51 | var height: Int? = null 52 | 53 | /** 54 | * Whether to create a new Window or Tab (chrome-only, false by default). 55 | */ 56 | var newWindow: Boolean? = null 57 | 58 | /** 59 | * Whether to create the target in background or foreground (chrome-only, false by default). 60 | */ 61 | var background: Boolean? = null 62 | } 63 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/org/hildan/chrome/devtools/protocol/FCEnumSerializerTest.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | import kotlinx.serialization.json.* 4 | import org.hildan.chrome.devtools.domains.accessibility.* 5 | import org.hildan.chrome.devtools.domains.bluetoothemulation.* 6 | import kotlin.test.* 7 | 8 | class FCEnumSerializerTest { 9 | 10 | @Test 11 | fun deserializesKnownValues() { 12 | assertEquals(AXPropertyName.url, Json.decodeFromString("\"url\"")) 13 | assertEquals(AXPropertyName.level, Json.decodeFromString("\"level\"")) 14 | assertEquals(AXPropertyName.hiddenRoot, Json.decodeFromString("\"hiddenRoot\"")) 15 | assertEquals(AXPropertyName.notRendered, Json.decodeFromString("\"notRendered\"")) 16 | assertEquals(AXPropertyName.uninteresting, Json.decodeFromString("\"uninteresting\"")) 17 | } 18 | 19 | @Test 20 | fun deserializesKnownValues_withDashes() { 21 | assertEquals(CentralState.poweredOn, Json.decodeFromString("\"powered-on\"")) 22 | assertEquals(CentralState.poweredOff, Json.decodeFromString("\"powered-off\"")) 23 | } 24 | 25 | @Test 26 | fun deserializesUnknownValues() { 27 | assertEquals(AXPropertyName.NotDefinedInProtocol("totallyInexistentStuff"), Json.decodeFromString("\"totallyInexistentStuff\"")) 28 | } 29 | 30 | @Test 31 | fun serializesKnownValues() { 32 | assertEquals("\"url\"", Json.encodeToString(AXPropertyName.url)) 33 | assertEquals("\"level\"", Json.encodeToString(AXPropertyName.level)) 34 | assertEquals("\"hiddenRoot\"", Json.encodeToString(AXPropertyName.hiddenRoot)) 35 | } 36 | 37 | @Test 38 | fun serializesKnownValues_withDashes() { 39 | assertEquals("\"powered-on\"", Json.encodeToString(CentralState.poweredOn)) 40 | assertEquals("\"powered-off\"", Json.encodeToString(CentralState.poweredOff)) 41 | assertEquals("\"notRendered\"", Json.encodeToString(AXPropertyName.notRendered)) 42 | assertEquals("\"uninteresting\"", Json.encodeToString(AXPropertyName.uninteresting)) 43 | } 44 | 45 | @Test 46 | fun serializesUnknownValues() { 47 | assertEquals("\"totallyInexistentStuff\"", Json.encodeToString(AXPropertyName.NotDefinedInProtocol("totallyInexistentStuff"))) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/extensions/CrossDomainExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.extensions 2 | 3 | import org.hildan.chrome.devtools.domains.dom.CssSelector 4 | import org.hildan.chrome.devtools.domains.dom.getBoxModel 5 | import org.hildan.chrome.devtools.domains.input.MouseButton 6 | import org.hildan.chrome.devtools.domains.input.dispatchMouseClick 7 | import org.hildan.chrome.devtools.domains.utils.center 8 | import org.hildan.chrome.devtools.sessions.PageSession 9 | import kotlin.time.* 10 | import kotlin.time.Duration.Companion.milliseconds 11 | 12 | /** 13 | * Finds a DOM element via the given [selector], and simulates a click event on it based on its padding box. 14 | * The current tab doesn't need to be focused. 15 | * 16 | * If this click opens a new tab, that new tab may become focused, but this session still targets the old tab. 17 | */ 18 | suspend fun PageSession.clickOnElement( 19 | selector: CssSelector, 20 | clickDuration: Duration = 100.milliseconds, 21 | mouseButton: MouseButton = MouseButton.left, 22 | ) { 23 | val box = dom.getBoxModel(selector) 24 | ?: error("Cannot click on element, no node found using selector '$selector'. " + 25 | "If the node might appear later, use PageSession.dom.awaitNodeBySelector(...) first.") 26 | val elementCenter = box.content.center 27 | 28 | input.dispatchMouseClick( 29 | x = elementCenter.x, 30 | y = elementCenter.y, 31 | clickDuration = clickDuration, 32 | button = mouseButton, 33 | ) 34 | } 35 | 36 | /** 37 | * Finds a DOM element via the given [selector], and simulates a click event on it based on its padding box. 38 | * The current tab doesn't need to be focused. 39 | * 40 | * If this click opens a new tab, that new tab may become focused, but this session still targets the old tab. 41 | */ 42 | @Deprecated( 43 | message = "Use clickOnElement with a Duration type for clickDuration.", 44 | replaceWith = ReplaceWith( 45 | expression = "this.clickOnElement(selector, clickDurationMillis.milliseconds, mouseButton)", 46 | imports = ["kotlin.time.Duration.Companion.milliseconds"], 47 | ), 48 | level = DeprecationLevel.ERROR, 49 | ) 50 | suspend fun PageSession.clickOnElement( 51 | selector: CssSelector, 52 | clickDurationMillis: Long, 53 | mouseButton: MouseButton = MouseButton.left, 54 | ) { 55 | clickOnElement(selector, clickDurationMillis.milliseconds, mouseButton) 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "New semver version without cdp suffix" 8 | required: true 9 | doPublish: 10 | type: boolean 11 | description: "Publish to maven (if unchecked, only the changelog, GitHub release, and git tag are done)" 12 | required: true 13 | default: true 14 | 15 | run-name: "Release ${{ inputs.version }}-(cdp-version)" 16 | 17 | jobs: 18 | release: 19 | runs-on: macos-15-intel # ARM runners don't support nested virtualization 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v6 23 | with: 24 | token: ${{ secrets.GH_PAT }} # to bypass branch protection rules to push the changelog 25 | 26 | - name: Set up JDK 27 | uses: actions/setup-java@v5 28 | with: 29 | distribution: 'temurin' 30 | java-version: 21 31 | 32 | # Docker is not installed on GitHub's MacOS hosted workers due to licensing issues 33 | - name: Setup docker (missing on MacOS) 34 | if: runner.os == 'macos' 35 | # colima is not installed in macos-15-intel, so we need this action now 36 | uses: douglascamata/setup-docker-macos-action@v1.0.2 37 | 38 | # For testcontainers to find the Colima socket 39 | # https://github.com/abiosoft/colima/blob/main/docs/FAQ.md#cannot-connect-to-the-docker-daemon-at-unixvarrundockersock-is-the-docker-daemon-running 40 | - name: Map docker.sock to colima (macOS only) 41 | if: runner.os == 'macos' 42 | run: | 43 | sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock 44 | 45 | - name: Cache Kotlin/Native binaries 46 | uses: actions/cache@v5 47 | with: 48 | path: ~/.konan 49 | key: konan-${{ runner.os }} 50 | 51 | - name: Compute full version (with protocol version) 52 | run: echo "fullVersion=${{ inputs.version }}-$(cat protocol-definition/version.txt)" >> $GITHUB_OUTPUT 53 | id: compute-version 54 | 55 | - name: Release 56 | uses: joffrey-bion/gradle-library-release-action@v2 57 | env: 58 | BROWSERLESS_API_TOKEN: ${{ secrets.BROWSERLESS_API_TOKEN }} 59 | with: 60 | version: ${{ steps.compute-version.outputs.fullVersion }} 61 | gpg-signing-key: ${{ secrets.GPG_SECRET_ASCII_ARMORED }} 62 | gpg-signing-password: ${{ secrets.GPG_PASSWORD }} 63 | sonatype-username: ${{ secrets.MAVEN_CENTRAL_TOKEN_USERNAME }} 64 | sonatype-password: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASSWORD }} 65 | gradle-publish-tasks: ${{ inputs.doPublish && 'publishAndReleaseToMavenCentral' || '' }} 66 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [plugins] 2 | 3 | binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } 4 | dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } 5 | hildan-github-changelog = { id = "org.hildan.github.changelog", version.ref = "hildan-github-changelog-plugin" } 6 | hildan-kotlin-publish = { id = "org.hildan.kotlin-publish", version.ref = "hildan-kotlin-publish-plugin" } 7 | kotlin-atomicfu = { id = "org.jetbrains.kotlin.plugin.atomicfu", version.ref = "kotlin" } 8 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 9 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 10 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 11 | vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktech-maven-publish-plugin" } 12 | vyarus-github-info = { id = "ru.vyarus.github-info", version.ref = "vyarus-github-info-plugin" } 13 | 14 | [versions] 15 | 16 | binary-compatibility-validator = "0.18.1" 17 | dokka = "2.1.0" 18 | hildan-kotlin-publish-plugin = "1.7.0" 19 | hildan-github-changelog-plugin = "2.2.0" 20 | kotlin = "2.2.21" 21 | kotlinpoet = "2.2.0" 22 | kotlinx-atomicfu = "0.29.0" 23 | kotlinx-coroutines = "1.10.2" 24 | kotlinx-serialization = "1.9.0" 25 | ktor = "3.3.3" 26 | slf4j = "2.0.17" 27 | testcontainers = "1.21.4" 28 | vanniktech-maven-publish-plugin = "0.35.0" 29 | vyarus-github-info-plugin = "2.0.0" 30 | 31 | [libraries] 32 | 33 | kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet"} 34 | kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinx-atomicfu" } 35 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 36 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } 37 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 38 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 39 | ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 40 | ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } 41 | ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 42 | slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j"} 43 | testcontainers-base = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } 44 | testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" } 45 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/protocol/SessionSerialization.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.filter 5 | import kotlinx.coroutines.flow.map 6 | import kotlinx.coroutines.flow.mapNotNull 7 | import kotlinx.serialization.DeserializationStrategy 8 | import kotlinx.serialization.SerializationStrategy 9 | import kotlinx.serialization.serializer 10 | 11 | /** 12 | * Sends a request with the given [methodName] and [requestParams] object, and suspends until the response is received. 13 | * 14 | * The request payload is serialized, and the response payload is deserialized, using serializers inferred from their 15 | * reified types. 16 | */ 17 | internal suspend inline fun ChromeDPSession.request(methodName: String, requestParams: I?): O = 18 | request(methodName, requestParams, serializer = serializer(), deserializer = serializer()) 19 | 20 | /** 21 | * Sends a request with the given [methodName] and [requestParams] object, and suspends until the response is received. 22 | * 23 | * The request payload is serialized using [serializer], and the response payload is deserialized using [deserializer]. 24 | */ 25 | internal suspend fun ChromeDPSession.request( 26 | methodName: String, 27 | requestParams: I?, 28 | serializer: SerializationStrategy, 29 | deserializer: DeserializationStrategy, 30 | ): O { 31 | val jsonParams = requestParams?.let { chromeDpJson.encodeToJsonElement(serializer, it) } 32 | val response = request(methodName, jsonParams) 33 | return chromeDpJson.decodeFromJsonElement(deserializer, response.payload) 34 | } 35 | 36 | /** 37 | * Subscribes to events of the given [eventName], converting their payload to instances of [E]. 38 | */ 39 | internal inline fun ChromeDPSession.typedEvents(eventName: String): Flow = typedEvents(eventName, 40 | serializer()) 41 | 42 | /** 43 | * Subscribes to events of the given [eventName], converting their payload to instances of [E] using [deserializer]. 44 | */ 45 | internal fun ChromeDPSession.typedEvents(eventName: String, deserializer: DeserializationStrategy): Flow = events() 46 | .filter { it.eventName == eventName } 47 | .map { it.decodePayload(deserializer) } 48 | 49 | /** 50 | * Subscribes to events whose names are in the provided [deserializers] map, converting their payload to subclasses 51 | * of [E] using the corresponding deserializer in the map. 52 | */ 53 | internal fun ChromeDPSession.typedEvents(deserializers: Map>): Flow = events() 54 | .mapNotNull { f -> deserializers[f.eventName]?.let { f.decodePayload(it) } } 55 | 56 | private fun EventFrame.decodePayload(deserializer: DeserializationStrategy): T = 57 | chromeDpJson.decodeFromJsonElement(deserializer, payload) 58 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/TestServer.kt: -------------------------------------------------------------------------------- 1 | import com.sun.net.httpserver.* 2 | import org.junit.jupiter.api.extension.* 3 | import java.io.* 4 | import java.net.* 5 | 6 | class TestResourcesServerExtension : Extension, BeforeAllCallback, AfterAllCallback { 7 | private lateinit var httpServer: HttpServer 8 | 9 | val port: Int 10 | get() = httpServer.address.port 11 | 12 | override fun beforeAll(context: ExtensionContext?) { 13 | httpServer = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 10) 14 | httpServer.createContext("/") { exchange -> 15 | if (exchange.requestMethod != "GET") { 16 | exchange.respondInvalidMethod(listOf("GET")) 17 | return@createContext 18 | } 19 | val resourcePath = exchange.requestURI.path.removePrefix("/") 20 | exchange.respondWithResource(resourcePath) 21 | } 22 | httpServer.start() 23 | } 24 | 25 | override fun afterAll(context: ExtensionContext?) { 26 | httpServer.stop(0) 27 | } 28 | } 29 | 30 | /** 31 | * Creates an HTTP server that serves Java resources from the current program at '/'. 32 | */ 33 | suspend fun withResourceHttpServer(block: suspend (port: Int) -> Unit) { 34 | val httpServer = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 10) 35 | try { 36 | httpServer.createContext("/") { exchange -> 37 | if (exchange.requestMethod != "GET") { 38 | exchange.respondInvalidMethod(listOf("GET")) 39 | return@createContext 40 | } 41 | val resourcePath = exchange.requestURI.path.removePrefix("/") 42 | exchange.respondWithResource(resourcePath) 43 | } 44 | 45 | httpServer.start() 46 | 47 | block(httpServer.address.port) 48 | } finally { 49 | httpServer.stop(15) 50 | } 51 | } 52 | 53 | private fun HttpExchange.respondInvalidMethod(supportedMethods: List) { 54 | responseHeaders.add("Allow", supportedMethods.joinToString(", ")) 55 | sendResponse(code = 405) 56 | } 57 | 58 | private fun HttpExchange.sendResponse(code: Int, body: String? = null) { 59 | if (body == null) { 60 | sendResponseHeaders(code, -1) 61 | responseBody.close() 62 | return 63 | } 64 | val bytes = body.encodeToByteArray() 65 | sendResponseHeaders(code, bytes.size.toLong()) 66 | responseBody.buffered().use { it.write(bytes) } 67 | } 68 | 69 | private fun HttpExchange.respondWithResource(resourcePath: String) { 70 | val resStream = ClassLoader.getSystemResourceAsStream(resourcePath) 71 | if (resStream == null) { 72 | sendResponseHeaders(404, -1) 73 | responseBody.close() 74 | return 75 | } 76 | sendResponseHeaders(200, 0) 77 | resStream.useAndWriteTo(responseBody) 78 | } 79 | 80 | private fun InputStream.useAndWriteTo(stream: OutputStream) { 81 | use { input -> input.copyTo(stream) } 82 | stream.flush() 83 | stream.close() 84 | } 85 | -------------------------------------------------------------------------------- /protocol-generator/cdp-json-parser/src/main/kotlin/org/hildan/chrome/devtools/protocol/json/TargetType.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.json 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.json.* 5 | import java.nio.file.Path 6 | 7 | /** 8 | * A target type in the general sense of "a Chromium devtools_agent_host implementation". 9 | * 10 | * This is different from the protocol's notion of "target type". The `TargetInfo.type` field 11 | * [in the protocol definition](https://chromedevtools.github.io/devtools-protocol/tot/Target/#type-TargetInfo) is a 12 | * more fine-grained segregation of targets. Each Chromium target type implementation can actually support multiple 13 | * protocol target types (see [supportedCdpTargets]). 14 | * 15 | * In addition to that, each Chromium target type implementation supports a different set of protocol domains, and this 16 | * information is not available in the protocol definition (alas!). 17 | * 18 | * In Chromium's code, multiple `_devtools_agent_host.cc` files correspond to these Chromium target types, and 19 | * they register different sets of domains, and can handle different protocol target types. We have to read the sources 20 | * to know which domains and which target types are supported (see protocol/README.md). 21 | */ 22 | @Serializable 23 | data class TargetType( 24 | /** 25 | * The name chosen for this target type in the Kotlin representation of chrome-devtools-kotlin. 26 | */ 27 | val kotlinName: String, 28 | /** 29 | * The class in Chromium sources defining this target type. 30 | */ 31 | val chromiumAgentHostType: String, 32 | /** 33 | * Target type names (as returned by the protocol in `TargetInfo.type`) supported by this Chromium target type. 34 | * 35 | * This is inferred from usages of the `const char DevToolsAgentHost` 36 | * [constants in Chromium's source](https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?q=%22const%20char%20DevToolsAgentHost::kType%22) 37 | */ 38 | val supportedCdpTargets: List, 39 | /** 40 | * The domains supported by this target type, as found in Chromium's sources. 41 | * 42 | * This is based on the [registration of domain handlers in Chromium's source](https://source.chromium.org/search?q=%22session-%3ECreateAndAddHandler%22%20f:devtools&ss=chromium). 43 | */ 44 | val supportedDomainsInChromium: List, 45 | /** 46 | * Additional domains that are effectively supported by this target type, despite not being found in Chromium 47 | * sources. They are demonstrably present based on integration tests with the zenika/alpine-chrome Docker image. 48 | */ 49 | val additionalSupportedDomains: List = emptyList(), 50 | ) { 51 | /** 52 | * The domains supported by this target type. 53 | */ 54 | val supportedDomains = supportedDomainsInChromium + additionalSupportedDomains 55 | 56 | companion object { 57 | fun parseJson(path: Path): List = Json.decodeFromString(path.toFile().readText()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/ManualTests.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.delay 2 | import org.hildan.chrome.devtools.ChromeDP 3 | import org.hildan.chrome.devtools.extensions.clickOnElement 4 | import org.hildan.chrome.devtools.sessions.asPageSession 5 | import org.hildan.chrome.devtools.sessions.childPages 6 | import org.hildan.chrome.devtools.sessions.goto 7 | import org.hildan.chrome.devtools.sessions.newPage 8 | import java.nio.file.Paths 9 | 10 | // Run an actual browser first: 11 | // "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir=C:\remote-profile 12 | 13 | suspend fun main() { 14 | testCrossOriginIFrame() 15 | } 16 | 17 | private suspend fun testCrossOriginIFrame() { 18 | println(Paths.get("../resources/page-with-cross-origin-iframe.html").toAbsolutePath()) 19 | val chromeClient = ChromeDP.httpApi("http://localhost:9222") 20 | val browserSession = chromeClient.webSocket() 21 | 22 | println("Connected to browser") 23 | delay(1000) 24 | 25 | val page = browserSession.newPage() 26 | page.goto(url = "file://C:\\Projects\\chrome-devtools-kotlin\\src\\test\\resources\\page-with-cross-origin-iframe.html") 27 | println("Navigated to page") 28 | 29 | val targets = browserSession.target.getTargets() 30 | println("Targets:\n${targets.targetInfos}") 31 | delay(1000) 32 | 33 | val iFrameTarget = targets.targetInfos.first { it.type == "iframe" } 34 | val iFrameSession = browserSession.attachToTarget(iFrameTarget.targetId).asPageSession() 35 | 36 | delay(3000) 37 | 38 | iFrameSession.clickOnElement(selector = "a") 39 | println("Clicked on link") 40 | delay(3000) 41 | 42 | val newTarget = page.childPages().firstOrNull() 43 | println(newTarget) 44 | 45 | delay(1000) 46 | iFrameSession.close(keepBrowserContext = true) 47 | println("Closed to child page, but also the containing page in the process") 48 | 49 | delay(3000) 50 | browserSession.close() 51 | println("Closed browser connection") 52 | } 53 | 54 | private suspend fun testChildPage() { 55 | println(Paths.get("../resources/child.html").toAbsolutePath()) 56 | val browserSession = ChromeDP.connect("http://localhost:9222") 57 | 58 | println("Connected to browser") 59 | delay(1000) 60 | 61 | val page = browserSession.newPage() 62 | page.goto(url = "file://C:\\Projects\\chrome-devtools-kotlin\\src\\test\\resources\\page.html") 63 | println("Navigated to Google") 64 | delay(5000) 65 | 66 | page.clickOnElement(selector = "a") 67 | println("Clicked on link") 68 | delay(3000) 69 | 70 | val newTarget = page.childPages().first() 71 | println(newTarget) 72 | 73 | delay(2000) 74 | val page2 = browserSession.attachToTarget(newTarget.targetId).asPageSession() 75 | println("Attached to child page") 76 | 77 | delay(1000) 78 | page2.close(keepBrowserContext = true) 79 | println("Closed to child page") 80 | 81 | delay(3000) 82 | page.close(keepBrowserContext = true) 83 | println("Closed page") 84 | delay(3000) 85 | browserSession.close() 86 | println("Closed browser connection") 87 | } 88 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/sessions/InternalSessions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.sessions 2 | 3 | import org.hildan.chrome.devtools.domains.target.* 4 | import org.hildan.chrome.devtools.protocol.ChromeDPSession 5 | import org.hildan.chrome.devtools.protocol.ExperimentalChromeApi 6 | import org.hildan.chrome.devtools.protocol.withSession 7 | import org.hildan.chrome.devtools.targets.* 8 | import org.hildan.chrome.devtools.targets.UberTarget 9 | 10 | internal fun ChromeDPSession.asBrowserSession(): BrowserSession = BrowserSessionImpl(this) 11 | 12 | private sealed class AbstractSession( 13 | protected val session: ChromeDPSession, 14 | protected val targetImplementation: UberTarget, 15 | ) : ChromeSession { 16 | 17 | override fun unsafe(): AllDomainsTarget = targetImplementation 18 | 19 | override suspend fun closeWebSocket() { 20 | session.closeWebSocket() 21 | } 22 | } 23 | 24 | private class BrowserSessionImpl( 25 | session: ChromeDPSession, 26 | targetImplementation: UberTarget = UberTarget(session), 27 | ) : AbstractSession(session, targetImplementation), BrowserSession, BrowserTarget by targetImplementation { 28 | 29 | @OptIn(ExperimentalChromeApi::class) 30 | override suspend fun attachToTarget(targetId: TargetID): ChildSession { 31 | // We use the "flatten" mode because it's required by our implementation of the protocol 32 | // (namely, we specify sessionId as part of the request frames directly, see RequestFrame) 33 | val sessionId = target.attachToTarget(targetId = targetId) { flatten = true }.sessionId 34 | val targetInfo = target.getTargetInfo { this.targetId = targetId }.targetInfo 35 | return ChildSessionImpl( 36 | session = session.connection.withSession(sessionId = sessionId), 37 | parent = this, 38 | metaData = MetaData(sessionId, targetInfo), 39 | ) 40 | } 41 | 42 | override suspend fun close() { 43 | closeWebSocket() 44 | } 45 | } 46 | 47 | private class ChildSessionImpl( 48 | session: ChromeDPSession, 49 | override val parent: BrowserSession, 50 | override val metaData: MetaData, 51 | targetImplementation: UberTarget = UberTarget(session), 52 | ) : AbstractSession(session, targetImplementation), 53 | ChildSession, 54 | AllDomainsTarget by targetImplementation { 55 | 56 | override suspend fun detach() { 57 | parent.target.detachFromTarget { sessionId = session.sessionId } 58 | } 59 | 60 | @OptIn(ExperimentalChromeApi::class) 61 | override suspend fun close(keepBrowserContext: Boolean) { 62 | parent.target.closeTarget(targetId = metaData.targetId) 63 | 64 | if (!keepBrowserContext && !metaData.target.browserContextId.isNullOrEmpty()) { 65 | parent.target.disposeBrowserContext(metaData.target.browserContextId) 66 | } 67 | } 68 | } 69 | 70 | private data class MetaData( 71 | override val sessionId: SessionID, 72 | val target: TargetInfo, 73 | ) : SessionMetaData { 74 | override val targetId: TargetID 75 | get() = target.targetId 76 | 77 | override val targetType: String 78 | get() = target.type 79 | } 80 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/names/Names.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.names 2 | 3 | import com.squareup.kotlinpoet.ClassName 4 | 5 | /** 6 | * The name of the synthetic enum entry used to represent deserialized values that are not defined in the protocol. 7 | */ 8 | // Note: 'unknown' and 'undefined' already exist in some of the enums, so we want to keep this one different 9 | const val UndefinedEnumEntryName = "NotDefinedInProtocol" 10 | 11 | @JvmInline 12 | value class DomainNaming( 13 | val domainName: String, 14 | ) { 15 | val filename 16 | get() = "${domainName}Domain" 17 | 18 | val packageName 19 | get() = "$ROOT_PACKAGE_NAME.domains.${domainName.lowercase()}" 20 | 21 | val domainClassName 22 | get() = ClassName(packageName, "${domainName}Domain") 23 | 24 | val typesFilename 25 | get() = "${domainName}Types" 26 | 27 | val eventsFilename 28 | get() = "${domainName}Events" 29 | 30 | // Distinct events sub-package to avoid conflicts with domain types 31 | val eventsPackageName 32 | get() = "$packageName.events" 33 | 34 | val eventsParentClassName 35 | get() = ClassName(eventsPackageName, "${this}Event") 36 | 37 | val targetFieldName 38 | get() = when { 39 | domainName[1].isLowerCase() -> domainName.replaceFirstChar { it.lowercase() } 40 | domainName.all { it.isUpperCase() } -> domainName.lowercase() 41 | else -> { 42 | // This handles domains starting with acronyms (DOM, CSS...) by lowercasing the whole acronym 43 | val firstLowercaseIndex = domainName.indexOfFirst { it.isLowerCase() } 44 | domainName.substring(0, firstLowercaseIndex - 1).lowercase() + domainName.substring( 45 | firstLowercaseIndex - 1) 46 | } 47 | } 48 | 49 | override fun toString(): String = domainName 50 | } 51 | 52 | sealed class NamingConvention 53 | 54 | data class DomainTypeNaming( 55 | val declaredName: String, 56 | val domain: DomainNaming, 57 | ) : NamingConvention() { 58 | val packageName = domain.packageName 59 | val className = ClassName(packageName, declaredName) 60 | } 61 | 62 | data class CommandNaming( 63 | val commandName: String, 64 | val domain: DomainNaming, 65 | ) : NamingConvention() { 66 | val fullCommandName = "${domain.domainName}.$commandName" 67 | val methodName = commandName 68 | val inputTypeName = ClassName(domain.packageName, "${commandName.capitalize()}Request") 69 | val inputTypeBuilderName = inputTypeName.nestedClass("Builder") 70 | val outputTypeName = ClassName(domain.packageName, "${commandName.capitalize()}Response") 71 | } 72 | 73 | data class EventNaming( 74 | val eventName: String, 75 | val domain: DomainNaming, 76 | ) : NamingConvention() { 77 | val fullEventName = "${domain.domainName}.${eventName}" 78 | val flowMethodName = "${eventName}Events" 79 | val legacyMethodName = eventName 80 | val eventTypeName = domain.eventsParentClassName.nestedClass(eventName.capitalize()) 81 | } 82 | 83 | private fun String.capitalize() = replaceFirstChar { it.titlecase() } 84 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/ZenikaIntegrationTests.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.* 2 | import org.hildan.chrome.devtools.* 3 | import org.hildan.chrome.devtools.domains.dom.* 4 | import org.hildan.chrome.devtools.protocol.* 5 | import org.hildan.chrome.devtools.sessions.* 6 | import org.junit.jupiter.api.Test 7 | import org.testcontainers.containers.* 8 | import org.testcontainers.junit.jupiter.* 9 | import org.testcontainers.junit.jupiter.Container 10 | import java.time.* 11 | import kotlin.test.* 12 | 13 | @Testcontainers 14 | class ZenikaIntegrationTests : LocalIntegrationTestBase() { 15 | 16 | /** 17 | * A container running the "raw" Chrome with support for the JSON HTTP API of the DevTools protocol (in addition to 18 | * the web socket API). 19 | * 20 | * One must first connect via the HTTP API at `http://localhost:{port}` and then get the web socket URL from there. 21 | */ 22 | @Container 23 | var zenikaChrome: GenericContainer<*> = GenericContainer("zenika/alpine-chrome:latest") 24 | .withStartupTimeout(Duration.ofMinutes(5)) // sometimes more than the default 2 minutes on CI 25 | .withExposedPorts(9222) 26 | .withAccessToHost(true) 27 | .withCommand("--no-sandbox --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 about:blank") 28 | 29 | override val httpUrl: String 30 | get() = "http://${zenikaChrome.host}:${zenikaChrome.getMappedPort(9222)}" 31 | 32 | // the WS URL is not known in advance and needs to be queried first via the HTTP API, hence the HTTP URL here 33 | override val wsConnectUrl: String 34 | get() = "http://${zenikaChrome.host}:${zenikaChrome.getMappedPort(9222)}" 35 | 36 | @Ignore("The Zenika container seems out of date and still treats cookiePartitionKey as a string instead of object") 37 | override fun missingExpiresInCookie() { 38 | } 39 | 40 | @OptIn(LegacyChromeTargetHttpApi::class) 41 | @Test 42 | fun httpTabEndpoints_newTabWithCustomUrl() = runTestWithRealTime { 43 | val chrome = chromeHttp() 44 | 45 | val googleTab = chrome.newTab(url = "https://www.google.com") 46 | assertEquals("https://www.google.com", googleTab.url.trimEnd('/')) 47 | 48 | val targets = chrome.targets() 49 | assertTrue( 50 | actual = targets.any { it.url.trimEnd('/') == "https://www.google.com" }, 51 | message = "the google.com page target should be listed, got:\n${targets.joinToString("\n")}", 52 | ) 53 | 54 | chrome.closeTab(googleTab.id) 55 | delay(100) // wait for the tab to actually close (fails on CI otherwise) 56 | 57 | val targetsAfterClose = chrome.targets() 58 | assertTrue( 59 | actual = targetsAfterClose.none { it.url.trimEnd('/') == "https://www.google.com" }, 60 | message = "the google.com page target should be closed, got:\n${targetsAfterClose.joinToString("\n")}", 61 | ) 62 | } 63 | 64 | @OptIn(ExperimentalChromeApi::class) 65 | @Test 66 | fun parallelPages() = runTestWithRealTime { 67 | chromeWebSocket().use { browser -> 68 | // we want all coroutines to finish before we close the browser session 69 | withContext(Dispatchers.IO) { 70 | repeat(20) { 71 | launch { 72 | browser.newPage().use { page -> 73 | page.gotoTestPageResource("basic.html") 74 | page.runtime.getHeapUsage() 75 | val docRoot = page.dom.getDocumentRootNodeId() 76 | page.dom.describeNode(DescribeNodeRequest(docRoot, depth = 2)) 77 | page.storage.getCookies() 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /protocol-definition/target_types.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "chromiumAgentHostType": "AuctionWorkletDevToolsAgentHost", 4 | "kotlinName": "AuctionWorklet", 5 | "supportedCdpTargets": [ 6 | "auction_worklet" 7 | ], 8 | "supportedDomainsInChromium": [ 9 | ] 10 | }, 11 | { 12 | "chromiumAgentHostType": "BrowserDevToolsAgentHost", 13 | "kotlinName": "Browser", 14 | "supportedCdpTargets": [ 15 | "browser" 16 | ], 17 | "supportedDomainsInChromium": [ 18 | "Target", 19 | "Browser", 20 | "IO", 21 | "Fetch", 22 | "Memory", 23 | "Security", 24 | "Storage", 25 | "SystemInfo", 26 | "Tethering", 27 | "Tracing" 28 | ] 29 | }, 30 | { 31 | "chromiumAgentHostType": "DedicatedWorkerDevToolsAgentHost", 32 | "kotlinName": "Worker", 33 | "supportedCdpTargets": [ 34 | "worker" 35 | ], 36 | "supportedDomainsInChromium": [ 37 | "IO", 38 | "Target", 39 | "Network" 40 | ] 41 | }, 42 | { 43 | "chromiumAgentHostType": "RenderFrameDevToolsAgentHost", 44 | "kotlinName": "Page", 45 | "supportedCdpTargets": [ 46 | "page", 47 | "iframe", 48 | "webview" 49 | ], 50 | "supportedDomainsInChromium": [ 51 | "Audits", 52 | "BackgroundService", 53 | "Browser", 54 | "DeviceAccess", 55 | "DeviceOrientation", 56 | "DOM", 57 | "Emulation", 58 | "FedCm", 59 | "Fetch", 60 | "Input", 61 | "Inspector", 62 | "IO", 63 | "Log", 64 | "Memory", 65 | "Network", 66 | "Overlay", 67 | "Page", 68 | "Preload", 69 | "Schema", 70 | "Security", 71 | "ServiceWorker", 72 | "Storage", 73 | "SystemInfo", 74 | "Target", 75 | "Tracing", 76 | "WebAuthn" 77 | ], 78 | "additionalSupportedDomains": [ 79 | "Accessibility", 80 | "Animation", 81 | "CacheStorage", 82 | "CSS", 83 | "Debugger", 84 | "DOMDebugger", 85 | "DOMSnapshot", 86 | "DOMStorage", 87 | "HeadlessExperimental", 88 | "HeapProfiler", 89 | "IndexedDB", 90 | "LayerTree", 91 | "Performance", 92 | "PerformanceTimeline", 93 | "Profiler", 94 | "Runtime" 95 | ] 96 | }, 97 | { 98 | "chromiumAgentHostType": "ServiceWorkerDevToolsAgentHost", 99 | "kotlinName": "ServiceWorker", 100 | "supportedCdpTargets": [ 101 | "service_worker" 102 | ], 103 | "supportedDomainsInChromium": [ 104 | "Fetch", 105 | "IO", 106 | "Inspector", 107 | "Network", 108 | "Schema", 109 | "Target" 110 | ] 111 | }, 112 | { 113 | "chromiumAgentHostType": "SharedStorageWorkletDevToolsAgentHost", 114 | "kotlinName": "SharedStorageWorklet", 115 | "supportedCdpTargets": [ 116 | "shared_storage_worklet" 117 | ], 118 | "supportedDomainsInChromium": [ 119 | "Inspector", 120 | "Target" 121 | ] 122 | }, 123 | { 124 | "chromiumAgentHostType": "SharedWorkerDevToolsAgentHost", 125 | "kotlinName": "SharedWorker", 126 | "supportedCdpTargets": [ 127 | "shared_worker" 128 | ], 129 | "supportedDomainsInChromium": [ 130 | "Fetch", 131 | "IO", 132 | "Inspector", 133 | "Network", 134 | "Schema", 135 | "Target" 136 | ] 137 | }, 138 | { 139 | "chromiumAgentHostType": "WebContentsDevToolsAgentHost", 140 | "kotlinName": "WebContents", 141 | "supportedCdpTargets": [ 142 | "tab" 143 | ], 144 | "supportedDomainsInChromium": [ 145 | "IO", 146 | "Target", 147 | "Tracing" 148 | ] 149 | }, 150 | { 151 | "chromiumAgentHostType": "WorkletDevToolsAgentHost", 152 | "kotlinName": "Worklet", 153 | "supportedCdpTargets": [ 154 | "worklet" 155 | ], 156 | "supportedDomainsInChromium": [ 157 | ] 158 | } 159 | ] 160 | -------------------------------------------------------------------------------- /protocol-generator/cdp-json-parser/src/main/kotlin/org/hildan/chrome/devtools/protocol/json/JsonProtocolTypes.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.json 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.json.* 5 | 6 | @Serializable 7 | data class ChromeProtocolDescriptor( 8 | val version: ChromeProtocolVersion, 9 | val domains: List 10 | ) { 11 | companion object { 12 | fun fromJson(json: String): ChromeProtocolDescriptor = Json.decodeFromString(json) 13 | } 14 | } 15 | 16 | @Serializable 17 | data class ChromeProtocolVersion( 18 | val major: Int, 19 | val minor: Int 20 | ) { 21 | override fun toString(): String = "$major.$minor" 22 | } 23 | 24 | @Serializable 25 | data class JsonDomain( 26 | val domain: String, 27 | val description: String? = null, 28 | val deprecated: Boolean = false, 29 | val experimental: Boolean = false, 30 | val dependencies: List = emptyList(), 31 | val types: List = emptyList(), 32 | val commands: List = emptyList(), 33 | val events: List = emptyList(), 34 | ) 35 | 36 | @Serializable 37 | data class JsonDomainType( 38 | val id: String, 39 | val description: String? = null, 40 | val deprecated: Boolean = false, 41 | val experimental: Boolean = false, 42 | val type: String, 43 | val properties: List = emptyList(), // only for type="object"? 44 | val enum: List? = null, // only for type="string"? 45 | val items: ArrayItemDescriptor? = null, // only for type="array"? 46 | /** 47 | * True for string types that have [enum] values that don't actually represent the whole set of possible values. 48 | * 49 | * This is a custom marker that's not part of the protocol. It's added to mitigate bugs in the protocol definition. 50 | */ 51 | @Transient 52 | val isNonExhaustiveEnum: Boolean = false, 53 | ) 54 | 55 | @Serializable 56 | data class JsonDomainParameter( 57 | val name: String, 58 | val description: String? = null, 59 | val type: String? = null, // null if $ref is present, string (even for enum), integer, boolean, array, object 60 | val optional: Boolean = false, 61 | val deprecated: Boolean = false, 62 | val experimental: Boolean = false, 63 | val enum: List? = null, // only for type="string"? 64 | val items: ArrayItemDescriptor? = null, // only for type="array"? 65 | val `$ref`: String? = null, 66 | /** 67 | * True for string types that have [enum] values that don't actually represent the whole set of possible values. 68 | * 69 | * This is a custom marker that's not part of the protocol. It's added to mitigate bugs in the protocol definition. 70 | */ 71 | @Transient 72 | val isNonExhaustiveEnum: Boolean = false, 73 | ) 74 | 75 | @Serializable 76 | data class ArrayItemDescriptor( 77 | val type: String? = null, // null if $ref is present, string (even for enum), integer, boolean, array?, object 78 | val enum: List? = null, // only for type="string"? 79 | val `$ref`: String? = null, 80 | /** 81 | * True for string types that have [enum] values that don't actually represent the whole set of possible values. 82 | * 83 | * This is a custom marker that's not part of the protocol. It's added to mitigate bugs in the protocol definition. 84 | */ 85 | @Transient 86 | val isNonExhaustiveEnum: Boolean = false, 87 | ) 88 | 89 | @Serializable 90 | data class JsonDomainCommand( 91 | val name: String, 92 | val description: String? = null, 93 | val deprecated: Boolean = false, 94 | val experimental: Boolean = false, 95 | val redirect: String? = null, 96 | val parameters: List = emptyList(), 97 | val returns: List = emptyList() 98 | ) 99 | 100 | @Serializable 101 | data class JsonDomainEvent( 102 | val name: String, 103 | val description: String? = null, 104 | val deprecated: Boolean = false, 105 | val experimental: Boolean = false, 106 | val parameters: List = emptyList() 107 | ) 108 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/preprocessing/JsonPreProcessor.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.preprocessing 2 | 3 | import org.hildan.chrome.devtools.protocol.json.JsonDomain 4 | import org.hildan.chrome.devtools.protocol.json.JsonDomainCommand 5 | import org.hildan.chrome.devtools.protocol.json.JsonDomainParameter 6 | import org.hildan.chrome.devtools.protocol.json.JsonDomainType 7 | 8 | /** 9 | * Pre-processes the list of domains from the JSON definitions to work around some issues in the definitions themselves. 10 | */ 11 | internal fun List.preprocessed(): List = this 12 | .makeNewExperimentalPropsOptional() 13 | // Workaround for https://github.com/ChromeDevTools/devtools-protocol/issues/317 14 | .transformDomainTypeProperty("Network", "Cookie", "expires") { it.copy(optional = true) } 15 | // We mark the enum as non-exhaustive, as suggested by Google folks, because more values may be unknown. 16 | // See https://issues.chromium.org/issues/444471169 17 | .transformDomainTypeProperty("Runtime", "RemoteObject", "subtype") { it.copy(isNonExhaustiveEnum = true) } 18 | // Workaround for https://github.com/ChromeDevTools/devtools-protocol/issues/244 19 | .map { it.pullNestedEnumsToTopLevel() } 20 | 21 | /** 22 | * Modifies some new experimental properties to make them optional, so that serialization doesn't fail on stable Chrome 23 | * versions. 24 | * 25 | * When new properties are added to experimental types in the most recent protocol versions, the stable Chrome doesn't 26 | * have them yet, and serialization fails because of these missing properties. This also affects tests which are using 27 | * Docker containers with the latest-ish stable Chrome version. 28 | */ 29 | // NOTE: only add properties that are not already in the latest stable. We don't want to make everything nullable. 30 | private fun List.makeNewExperimentalPropsOptional(): List = 31 | transformDomainCommandReturnProp("Runtime", "getHeapUsage", "embedderHeapUsedSize") { it.copy(optional = true) } 32 | .transformDomainCommandReturnProp("Runtime", "getHeapUsage", "backingStorageSize") { it.copy(optional = true) } 33 | 34 | private fun List.transformDomainTypeProperty( 35 | domain: String, 36 | type: String, 37 | property: String, 38 | transform: (JsonDomainParameter) -> JsonDomainParameter, 39 | ): List = transformDomain(domain) { d -> 40 | d.transformType(type) { t -> 41 | t.transformProperty(property, transform) 42 | } 43 | } 44 | 45 | private fun List.transformDomainCommandReturnProp( 46 | domain: String, 47 | command: String, 48 | property: String, 49 | transform: (JsonDomainParameter) -> JsonDomainParameter, 50 | ): List = transformDomain(domain) { d -> 51 | d.transformCommand(command) { t -> 52 | t.transformReturnProperty(property, transform) 53 | } 54 | } 55 | 56 | private fun List.transformDomain( 57 | name: String, 58 | transform: (JsonDomain) -> JsonDomain, 59 | ): List = transformIf({ it.domain == name }) { transform(it) } 60 | 61 | private fun JsonDomain.transformType( 62 | name: String, 63 | transform: (JsonDomainType) -> JsonDomainType, 64 | ): JsonDomain = copy(types = types.transformIf({ it.id == name }) { transform(it) }) 65 | 66 | private fun JsonDomainType.transformProperty( 67 | name: String, 68 | transform: (JsonDomainParameter) -> JsonDomainParameter, 69 | ): JsonDomainType = copy(properties = properties.transformIf({ it.name == name }) { transform(it) }) 70 | 71 | private fun JsonDomain.transformCommand( 72 | name: String, 73 | transform: (JsonDomainCommand) -> JsonDomainCommand, 74 | ): JsonDomain = copy(commands = commands.transformIf({ it.name == name }) { transform(it) }) 75 | 76 | private fun JsonDomainCommand.transformReturnProperty( 77 | name: String, 78 | transform: (JsonDomainParameter) -> JsonDomainParameter, 79 | ): JsonDomainCommand = copy(returns = returns.transformIf({ it.name == name }) { transform(it) }) 80 | 81 | private fun List.transformIf(predicate: (T) -> Boolean, transform: (T) -> T): List = 82 | map { if (predicate(it)) transform(it) else it } 83 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/protocol/ChromeDPSession.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | import kotlinx.atomicfu.* 4 | import kotlinx.coroutines.flow.* 5 | import kotlinx.serialization.json.* 6 | import org.hildan.chrome.devtools.domains.inspector.events.* 7 | import org.hildan.chrome.devtools.domains.target.events.* 8 | import org.hildan.chrome.devtools.domains.target.SessionID 9 | 10 | /** 11 | * Creates a [ChromeDPSession] backed by this connection to handle session-scoped request IDs and filter events of 12 | * the session with the given [sessionId]. The session ID may be null to represent the root browser sessions. 13 | */ 14 | internal fun ChromeDPConnection.withSession(sessionId: SessionID?) = ChromeDPSession(this, sessionId) 15 | 16 | /** 17 | * A wrapper around a [ChromeDPConnection] to handle session-scoped request IDs and filter events of a specific session. 18 | */ 19 | internal class ChromeDPSession( 20 | /** 21 | * The underlying connection to Chrome. 22 | */ 23 | val connection: ChromeDPConnection, 24 | /** 25 | * The ID of this session, or null if this is the root browser session. 26 | */ 27 | val sessionId: SessionID?, 28 | ) { 29 | /** 30 | * Ids must be unique at least within a session. 31 | */ 32 | private val nextRequestId = atomic(0L) 33 | 34 | /** 35 | * Sends a request with the given [methodName] and [params], and suspends until the response is received. 36 | */ 37 | suspend fun request(methodName: String, params: JsonElement?): ResponseFrame { 38 | val request = RequestFrame( 39 | id = nextRequestId.incrementAndGet(), 40 | method = methodName, 41 | params = params, 42 | sessionId = sessionId, 43 | ) 44 | return connection.request(request) 45 | } 46 | 47 | /** 48 | * Subscribes to all events tied to this session. 49 | */ 50 | fun events() = connection.events() 51 | .filter { it.sessionId == sessionId } 52 | .onEach { 53 | // We throw to immediately stop collectors when a target will not respond (instead of hanging). 54 | // Note that Inspector.targetCrashed events are received even without InspectorDomain.enable() call. 55 | if (it.eventName in crashEventNames) { 56 | throw TargetCrashedException(it.sessionId, it.eventName, it.payload) 57 | } 58 | } 59 | 60 | /** 61 | * Closes the underlying web socket connection, effectively closing every session based on the same web socket 62 | * connection. 63 | */ 64 | suspend fun closeWebSocket() { 65 | connection.close() 66 | } 67 | } 68 | 69 | private val crashEventNames = setOf("Inspector.targetCrashed", "Target.targetCrashed") 70 | 71 | /** 72 | * An exception thrown when an [InspectorEvent.TargetCrashed] or [TargetEvent.TargetCrashed] is received. 73 | */ 74 | @Suppress("CanBeParameter", "MemberVisibilityCanBePrivate") 75 | class TargetCrashedException( 76 | /** 77 | * The session ID of the target that crashed, or null if it is the root browser target. 78 | */ 79 | val sessionId: SessionID?, 80 | /** 81 | * The name of the event that triggered this exception. 82 | */ 83 | val crashEventName: String, 84 | /** 85 | * The payload of the crash event that triggered this exception 86 | */ 87 | val crashEventPayload: JsonElement, 88 | ) : Exception(buildTargetCrashedMessage(sessionId, crashEventName, crashEventPayload)) 89 | 90 | private fun buildTargetCrashedMessage(sessionId: SessionID?, crashEventName: String, payload: JsonElement): String { 91 | val payloadText = when (payload) { 92 | is JsonNull -> null 93 | is JsonPrimitive -> if (payload.isString) "\"${payload.content}\"" else payload.content 94 | is JsonObject -> if (payload.size > 0) payload.toString() else null 95 | is JsonArray -> if (payload.size > 0) payload.toString() else null 96 | } 97 | val payloadInfo = if (payloadText == null) "without payload." else "with payload: $payloadText" 98 | val eventInfo = "Received event '$crashEventName' $payloadInfo" 99 | return if (sessionId == null) { 100 | "The browser target has crashed. $eventInfo" 101 | } else { 102 | "The target with session ID $sessionId has crashed. $eventInfo" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/protocol/ChromeDPFrames.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | import kotlinx.serialization.json.* 7 | import org.hildan.chrome.devtools.domains.target.SessionID 8 | 9 | /** 10 | * [Json] serializer to use for Chrome DP frames. 11 | */ 12 | internal val chromeDpJson = Json { 13 | // frame payloads can evolve, and we shouldn't fail hard on deserialization when this happens 14 | ignoreUnknownKeys = true 15 | } 16 | 17 | /** 18 | * A request frame which can be sent to the server, as defined 19 | * [in the protocol's README](https://github.com/aslushnikov/getting-started-with-cdp/blob/master/README.md#protocol-fundamentals) 20 | */ 21 | @Serializable 22 | data class RequestFrame( 23 | /** Request id, must be unique in the current session. */ 24 | val id: Long, 25 | 26 | /** Protocol method (e.g. Page.navigateTo). */ 27 | val method: String, 28 | 29 | /** Request params (if any). */ 30 | val params: JsonElement? = null, 31 | 32 | /** Session ID for Target's flatten mode requests (see [http://crbug.com/991325](http://crbug.com/991325)). */ 33 | val sessionId: String? = null, 34 | ) 35 | 36 | /** 37 | * General interface represent both real incoming . 38 | */ 39 | internal sealed interface InboundFrameOrError 40 | 41 | /** 42 | * Represents errors in the incoming frames flow. 43 | */ 44 | internal class InboundFramesConnectionError(val cause: Throwable) : InboundFrameOrError 45 | 46 | /** 47 | * A generic inbound frame received from the server. It can represent responses to requests, or server-initiated events. 48 | */ 49 | internal sealed class InboundFrame : InboundFrameOrError { 50 | /** The session ID of the target concerned by this event. */ 51 | abstract val sessionId: SessionID? 52 | } 53 | 54 | /** 55 | * A polymorphic deserializer that picks the correct deserializer for the specific response or event frames based on 56 | * the JSON structure. 57 | */ 58 | internal object InboundFrameSerializer : JsonContentPolymorphicSerializer(InboundFrame::class) { 59 | // Events are distinguished from responses to requests by the absence of the 'id' field, as defined by: 60 | // https://github.com/aslushnikov/getting-started-with-cdp/blob/master/README.md#protocol-fundamentals 61 | // Successful and error responses are distinguished by the presence of either the 'result' or 'error' field. 62 | // Exactly one of these 2 fields must be present, see https://www.jsonrpc.org/specification#response_object 63 | override fun selectDeserializer(element: JsonElement): KSerializer { 64 | val id = element.jsonObject["id"] 65 | val error = element.jsonObject["error"] 66 | return when { 67 | id == null || id is JsonNull -> EventFrame.serializer() 68 | error != null && error !is JsonNull -> ErrorFrame.serializer() 69 | else -> ResponseFrame.serializer() 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * An event frame, received when events are enabled for a domain. 76 | */ 77 | @Serializable 78 | internal data class EventFrame( 79 | /** The event name of this event (e.g. "DOM.documentUpdated"). */ 80 | @SerialName("method") val eventName: String, 81 | 82 | /** The payload of this event. */ 83 | @SerialName("params") val payload: JsonElement, 84 | 85 | override val sessionId: SessionID? = null, 86 | ) : InboundFrame() 87 | 88 | /** 89 | * A frame received as a response to a request. 90 | */ 91 | @Serializable 92 | internal sealed class ResultFrame : InboundFrame() { 93 | /** The ID of the request that triggered this response. */ 94 | abstract val id: Long 95 | } 96 | 97 | /** 98 | * A successful response to a request. 99 | */ 100 | @Serializable 101 | internal data class ResponseFrame( 102 | override val id: Long, 103 | override val sessionId: SessionID? = null, 104 | @SerialName("result") val payload: JsonElement, 105 | ) : ResultFrame() 106 | 107 | /** 108 | * A frame received when there was an error processing the corresponding request. 109 | */ 110 | @Serializable 111 | internal data class ErrorFrame( 112 | override val id: Long, 113 | override val sessionId: SessionID? = null, 114 | val error: RequestError, 115 | ) : ResultFrame() 116 | 117 | /** 118 | * Data about an error that occurred when processing a request. 119 | */ 120 | @Serializable 121 | data class RequestError( 122 | /** Error code. */ 123 | val code: Long, 124 | 125 | /** Error message. */ 126 | val message: String, 127 | 128 | /** Associated error data. */ 129 | val data: JsonElement? = null 130 | ) 131 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/protocol/ChromeDPConnection.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | import io.ktor.websocket.* 4 | import kotlinx.coroutines.* 5 | import kotlinx.coroutines.flow.* 6 | import kotlin.coroutines.* 7 | 8 | /** 9 | * Wraps this [WebSocketSession] to provide Chrome DevTools Protocol capabilities. 10 | * 11 | * The returned [ChromeDPConnection] can be used to send requests and listen to events. 12 | * 13 | * It launches a coroutine internally to process and share incoming events. 14 | * The [eventProcessingContext] can be used to customize the context of this coroutine. 15 | */ 16 | internal fun WebSocketSession.chromeDp( 17 | eventProcessingContext: CoroutineContext = EmptyCoroutineContext, 18 | ): ChromeDPConnection = ChromeDPConnection(this, eventProcessingContext) 19 | 20 | /** 21 | * A connection to Chrome, providing communication primitives for the Chrome DevTools protocol. 22 | * 23 | * It encodes/decodes ChromeDP frames, and handles sharing of incoming events. 24 | * 25 | * It launches a coroutine internally to process and share incoming events. 26 | * The [eventProcessingContext] can be used to customize the context of this coroutine. 27 | */ 28 | internal class ChromeDPConnection( 29 | private val webSocket: WebSocketSession, 30 | eventProcessingContext: CoroutineContext = EmptyCoroutineContext, 31 | ) { 32 | private val coroutineScope = CoroutineScope(CoroutineName("ChromeDP-frame-decoder") + eventProcessingContext) 33 | 34 | private val frames = webSocket.incoming.receiveAsFlow() 35 | .filterIsInstance() 36 | .map { frame -> chromeDpJson.decodeFromString(InboundFrameSerializer, frame.readText()) } 37 | .materializeErrors() 38 | .shareIn( 39 | scope = coroutineScope, 40 | started = SharingStarted.Eagerly, 41 | ) 42 | 43 | /** 44 | * Sends the given ChromeDP [request], and returns the corresponding [ResponseFrame]. 45 | * 46 | * @throws RequestNotSentException if the socket is already closed and the request cannot be sent 47 | * @throws RequestFailed if the Chrome debugger returns an error frame 48 | */ 49 | suspend fun request(request: RequestFrame): ResponseFrame { 50 | val resultFrame = frames.onSubscription { sendOrFailUniformly(request) } 51 | .dematerializeErrors() 52 | .filterIsInstance() 53 | .filter { it.matchesRequest(request) } 54 | .first() // a shared flow never completes, so this will never throw NoSuchElementException (but can hang forever) 55 | 56 | when (resultFrame) { 57 | is ResponseFrame -> return resultFrame 58 | is ErrorFrame -> throw RequestFailed(request, resultFrame.error) 59 | } 60 | } 61 | 62 | private suspend fun sendOrFailUniformly(request: RequestFrame) { 63 | try { 64 | webSocket.send(chromeDpJson.encodeToString(request)) 65 | } catch (e: Exception) { 66 | // It's possible to get CancellationException without being cancelled, for example 67 | // when ChromeDPConnection.close() was called before calling request(). 68 | // Not sure why we don't get ClosedSendChannelException in that case - requires further investigation. 69 | currentCoroutineContext().ensureActive() 70 | throw RequestNotSentException(request, e) 71 | } 72 | } 73 | 74 | private fun ResultFrame.matchesRequest(request: RequestFrame): Boolean = 75 | // id is only unique within a session, so we need to check sessionId too 76 | id == request.id && sessionId == request.sessionId 77 | 78 | /** 79 | * A flow of incoming events. 80 | */ 81 | fun events() = frames.dematerializeErrors().filterIsInstance() 82 | 83 | /** 84 | * Stops listening to incoming events and closes the underlying web socket connection. 85 | */ 86 | suspend fun close() { 87 | coroutineScope.cancel() 88 | webSocket.close() 89 | } 90 | } 91 | 92 | private fun Flow.materializeErrors(): Flow = 93 | catch { emit(InboundFramesConnectionError(cause = it)) } 94 | 95 | private fun Flow.dematerializeErrors(): Flow = 96 | map { 97 | when (it) { 98 | is InboundFramesConnectionError -> throw it.cause 99 | is InboundFrame -> it 100 | } 101 | } 102 | 103 | /** 104 | * An exception thrown when an error occurred during the processing of a request on Chrome side. 105 | */ 106 | class RequestFailed(val request: RequestFrame, val error: RequestError) : Exception(error.message) 107 | 108 | /** 109 | * An exception thrown when an error prevented sending a request via the Chrome web socket. 110 | */ 111 | class RequestNotSentException( 112 | val request: RequestFrame, 113 | cause: Throwable?, 114 | ) : Exception("Could not send request '${request.method}': $cause", cause) 115 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/Generator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.FileSpec 4 | import org.hildan.chrome.devtools.protocol.json.ChromeProtocolDescriptor 5 | import org.hildan.chrome.devtools.protocol.json.TargetType 6 | import org.hildan.chrome.devtools.protocol.model.ChromeDPDomain 7 | import org.hildan.chrome.devtools.protocol.model.toChromeDPDomain 8 | import org.hildan.chrome.devtools.protocol.names.Annotations 9 | import org.hildan.chrome.devtools.protocol.names.ExtDeclarations 10 | import org.hildan.chrome.devtools.protocol.preprocessing.preprocessed 11 | import java.nio.file.Path 12 | import kotlin.io.path.* 13 | 14 | class Generator( 15 | /** 16 | * The paths to the JSON protocol descriptors. 17 | */ 18 | private val protocolFiles: List, 19 | /** 20 | * The path to the JSON file describing the target types and their supported domains. 21 | */ 22 | private val targetTypesFile: Path, 23 | /** 24 | * The path to the directory where the Kotlin protocol API classes should be generated. 25 | */ 26 | private val generatedSourcesDir: Path 27 | ) { 28 | @OptIn(ExperimentalPathApi::class) 29 | fun generate() { 30 | generatedSourcesDir.deleteRecursively() 31 | generatedSourcesDir.createDirectories() 32 | 33 | val domains = readProtocolDomains() 34 | domains.forEach(::generateDomainFiles) 35 | 36 | val domainsByName = domains.associateBy { it.names.domainName } 37 | 38 | val targets = TargetType.parseJson(targetTypesFile) 39 | targets.forEach { target -> 40 | generateTargetInterfaceFile( 41 | target = target, 42 | domains = target.supportedDomains.map { 43 | domainsByName[it] 44 | ?: error("Domain '$it' is not present in the protocol definitions, yet is marked as supported" + 45 | " for target type '${target.kotlinName}' (${target.chromiumAgentHostType})") 46 | }, 47 | ) 48 | } 49 | generateAllDomainsTargetInterfaceFile(allTargets = targets, allDomains = domains) 50 | generateAllDomainsTargetImplFile(allTargets = targets, allDomains = domains) 51 | generateChildSessionsFiles(childTargets = targets.filterNot { it.kotlinName == "Browser" }) 52 | } 53 | 54 | private fun readProtocolDomains(): List { 55 | val descriptors = protocolFiles.map { ChromeProtocolDescriptor.fromJson(it.readText()) } 56 | if (descriptors.distinctBy { it.version }.size > 1) { 57 | error("Some descriptors have differing versions: ${descriptors.map { it.version }}") 58 | } 59 | return descriptors.flatMap { it.domains }.preprocessed().map { it.toChromeDPDomain() } 60 | } 61 | 62 | private fun generateDomainFiles(domain: ChromeDPDomain) { 63 | if (domain.types.isNotEmpty()) { 64 | domain.createDomainTypesFileSpec().writeTo(generatedSourcesDir) 65 | } 66 | if (domain.events.isNotEmpty()) { 67 | domain.createDomainEventTypesFileSpec().writeTo(generatedSourcesDir) 68 | } 69 | domain.createDomainFileSpec().writeTo(generatedSourcesDir) 70 | } 71 | 72 | private fun generateTargetInterfaceFile(target: TargetType, domains: List) { 73 | val targetInterface = ExtDeclarations.targetInterface(target) 74 | FileSpec.builder(targetInterface.packageName, targetInterface.simpleName) 75 | .addAnnotation(Annotations.suppressWarnings) 76 | .addType(createTargetInterface(target, domains)) 77 | .build() 78 | .writeTo(generatedSourcesDir) 79 | } 80 | 81 | private fun generateAllDomainsTargetInterfaceFile(allTargets: List, allDomains: List) { 82 | val targetInterface = ExtDeclarations.allDomainsTargetInterface 83 | FileSpec.builder(targetInterface.packageName, targetInterface.simpleName) 84 | .addAnnotation(Annotations.suppressWarnings) 85 | .addType(createAllDomainsTargetInterface(allTargets, allDomains)) 86 | .build() 87 | .writeTo(generatedSourcesDir) 88 | } 89 | 90 | private fun generateAllDomainsTargetImplFile(allTargets: List, allDomains: List) { 91 | val targetClass = ExtDeclarations.allDomainsTargetImplementation 92 | FileSpec.builder(targetClass.packageName, targetClass.simpleName) 93 | .addAnnotation(Annotations.suppressWarnings) 94 | .addType(createAllDomainsTargetImpl(allTargets, allDomains)) 95 | .build() 96 | .writeTo(generatedSourcesDir) 97 | } 98 | 99 | private fun generateChildSessionsFiles(childTargets: List) { 100 | createSessionInterfacesFileSpec(childTargets).writeTo(generatedSourcesDir) 101 | createSessionAdaptersFileSpec(childTargets).writeTo(generatedSourcesDir) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/SessionGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.* 4 | import org.hildan.chrome.devtools.protocol.json.TargetType 5 | import org.hildan.chrome.devtools.protocol.names.ExtDeclarations 6 | 7 | fun createSessionInterfacesFileSpec(allTargets: List): FileSpec = 8 | FileSpec.builder(ExtDeclarations.sessionsPackage, ExtDeclarations.sessionsFileName).apply { 9 | allTargets.forEach { 10 | addType(createSessionInterface(it)) 11 | } 12 | }.build() 13 | 14 | private fun createSessionInterface(target: TargetType): TypeSpec = 15 | TypeSpec.interfaceBuilder(ExtDeclarations.sessionInterface(target)).apply { 16 | addKdoc("A session created when attaching to a target of type ${target.kotlinName}.") 17 | addKdoc("\n\n") 18 | // using concatenated strings here so KotlinPoet handles line breaks when it sees fit 19 | // (this is important when using dynamic type names in the sentences) 20 | addKdoc( 21 | format = "The subset of domains available for this target type is not strictly defined by the protocol. " + 22 | "The subset provided in this interface is guaranteed to work on this target type. " + 23 | "However, some domains might be missing in this interface while being effectively supported by the " + 24 | "target. " + 25 | "If this is the case, you can use the [%N] function to access all domains.", 26 | ExtDeclarations.childSessionUnsafeFun, 27 | ) 28 | addKdoc("\n\n") 29 | addKdoc( 30 | format = "As a subinterface of [%T], it inherits the generated domain properties that match the latest " + 31 | "Chrome DevToolsProtocol definitions. " + 32 | "As such, it is not stable for inheritance, as new properties can be added without major version bump" + 33 | " when the protocol changes. " + 34 | "It is however safe to use all non-experimental and non-deprecated domains defined here. " + 35 | "The experimental and deprecation cycles of the protocol are reflected in this interface with the " + 36 | "same guarantees.", 37 | ExtDeclarations.targetInterface(target), 38 | ) 39 | addSuperinterface(ExtDeclarations.childSessionInterface) 40 | addSuperinterface(ExtDeclarations.targetInterface(target)) 41 | }.build() 42 | 43 | fun createSessionAdaptersFileSpec(childTargets: List): FileSpec = 44 | FileSpec.builder(ExtDeclarations.sessionsPackage, ExtDeclarations.sessionAdaptersFileName).apply { 45 | childTargets.forEach { 46 | addFunction(createAdapterExtension(it)) 47 | addType(createSessionAdapterClass(it)) 48 | } 49 | }.build() 50 | 51 | private fun createSessionAdapterClass(target: TargetType): TypeSpec = 52 | TypeSpec.classBuilder(ExtDeclarations.sessionAdapter(target)).apply { 53 | addKdoc( 54 | "An adapter from a generic [%T] to a [%T].", 55 | ExtDeclarations.childSessionInterface, 56 | ExtDeclarations.sessionInterface(target) 57 | ) 58 | addModifiers(KModifier.PRIVATE) 59 | 60 | val sessionArg = "session" 61 | primaryConstructor( 62 | FunSpec.constructorBuilder() 63 | .addParameter(sessionArg, ExtDeclarations.childSessionInterface) 64 | .build() 65 | ) 66 | 67 | val targetInterface = ExtDeclarations.targetInterface(target) 68 | addSuperinterface(ExtDeclarations.sessionInterface(target)) 69 | addSuperinterface(ExtDeclarations.childSessionInterface, delegate = CodeBlock.of("%N", sessionArg)) 70 | addSuperinterface(targetInterface, delegate = CodeBlock.of("%N.unsafe()", sessionArg)) 71 | 72 | addInitializerBlock( 73 | CodeBlock.of( 74 | """ 75 | val targetType = %N.metaData.targetType 76 | require(targetType in %T.supportedCdpTargets) { 77 | %P 78 | } 79 | """.trimIndent(), 80 | sessionArg, 81 | targetInterface, 82 | CodeBlock.of( 83 | "Cannot initiate a ${target.kotlinName} session with a target of type ${'$'}targetType (target ID: ${'$'}{%N.metaData.targetId})", 84 | sessionArg 85 | ) 86 | ) 87 | ) 88 | }.build() 89 | 90 | private fun createAdapterExtension(target: TargetType): FunSpec { 91 | val sessionInterface = ExtDeclarations.sessionInterface(target) 92 | return FunSpec.builder("as${sessionInterface.simpleName}").apply { 93 | receiver(ExtDeclarations.childSessionInterface) 94 | addKdoc( 95 | "Adapts this [%T] to a [%T]. If the attached target is not of a compatible type, this function throws an exception.", 96 | ExtDeclarations.childSessionInterface, 97 | sessionInterface, 98 | ) 99 | addCode("return %T(this)", ExtDeclarations.sessionAdapter(target)) 100 | returns(sessionInterface) 101 | }.build() 102 | } 103 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/protocol/ChromeDPHttpApi.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol 2 | 3 | import kotlinx.serialization.* 4 | import org.hildan.chrome.devtools.* 5 | import org.hildan.chrome.devtools.sessions.* 6 | 7 | @RequiresOptIn( 8 | message = "This HTTP JSON endpoint is superseded by the richer web socket API, and might not be supported by " + 9 | "every headless browser. Consider using the web socket instead." 10 | ) 11 | annotation class LegacyChromeTargetHttpApi 12 | 13 | /** 14 | * An API to interact with Chrome's [JSON HTTP endpoints](https://chromedevtools.github.io/devtools-protocol/#endpoints). 15 | * 16 | * Use [ChromeDP.httpApi] to get an instance of this API. 17 | * 18 | * These endpoints should mostly be used to query metadata about the protocol. 19 | * Prefer the richer web socket API to interact with the browser using the Chrome DevTools Protocol. 20 | */ 21 | interface ChromeDPHttpApi { 22 | 23 | /** 24 | * Fetches the browser version metadata via the debugger's HTTP API. 25 | */ 26 | suspend fun version(): ChromeVersion 27 | 28 | /** 29 | * Fetches the current Chrome DevTools Protocol definition, as a JSON string. 30 | */ 31 | suspend fun protocolJson(): String 32 | 33 | /** 34 | * Fetches the list of all available web socket targets (e.g. browser tabs). 35 | */ 36 | @LegacyChromeTargetHttpApi 37 | suspend fun targets(): List 38 | 39 | /** 40 | * Opens a new tab, and returns the websocket target data for the new tab. 41 | */ 42 | @LegacyChromeTargetHttpApi 43 | suspend fun newTab(url: String = "about:blank"): ChromeDPTarget 44 | 45 | /** 46 | * Brings the page identified by the given [targetId] into the foreground (activates a tab). 47 | */ 48 | @LegacyChromeTargetHttpApi 49 | suspend fun activateTab(targetId: String): String 50 | 51 | /** 52 | * Closes the page identified by [targetId]. 53 | */ 54 | @LegacyChromeTargetHttpApi 55 | suspend fun closeTab(targetId: String): String 56 | 57 | /** 58 | * Closes all targets. 59 | */ 60 | @LegacyChromeTargetHttpApi 61 | suspend fun closeAllTargets() { 62 | targets().forEach { 63 | closeTab(it.id) 64 | } 65 | } 66 | 67 | /** 68 | * Opens a web socket connection to interact with the browser. 69 | * 70 | * This method attaches to the default browser target, which creates a root session without session ID. 71 | * The returned [BrowserSession] thus only provides a limited subset of the possible operations (only the ones 72 | * applicable to the browser itself). Refer to the documentation of [BrowserSession] to see how to use it to 73 | * attach to (and interact with) more specific targets. 74 | * 75 | * Child sessions of returned `BrowserSession` use the same underlying web socket connection as the initial browser 76 | * session returned here. 77 | * 78 | * Note that the caller of this method is responsible for closing the web socket after use by calling 79 | * [BrowserSession.close], or using the auto-close capabilities via [BrowserSession.use]. 80 | * Calling [ChildSession.close] or [ChildSession.use] on a derived session doesn't close the underlying web socket 81 | * connection, to avoid undesirable interactions between child sessions. 82 | */ 83 | suspend fun webSocket(): BrowserSession 84 | } 85 | 86 | /** 87 | * Browser version information retrieved via the debugger API. 88 | */ 89 | @Serializable 90 | data class ChromeVersion( 91 | @SerialName("Browser") val browser: String, 92 | @SerialName("Protocol-Version") val protocolVersion: String, 93 | @SerialName("User-Agent") val userAgent: String, 94 | @SerialName("V8-Version") val v8Version: String? = null, 95 | @SerialName("WebKit-Version") val webKitVersion: String, 96 | /** 97 | * The web socket URL to use to attach to the browser target. 98 | * It is sort of the "root" target that can then be used to connect to pages and other types of targets. 99 | * 100 | * The URL contains a unique ID for the browser target, such as: 101 | * `ws://localhost:9222/devtools/browser/b0b8a4fb-bb17-4359-9533-a8d9f3908bd8` 102 | */ 103 | @SerialName("webSocketDebuggerUrl") val webSocketDebuggerUrl: String, 104 | ) 105 | 106 | /** 107 | * Targets are the parts of the browser that the Chrome DevTools Protocol can interact with. 108 | * This includes pages, service workers, extensions, and also the browser itself. 109 | * 110 | * When a client wants to interact with a target using CDP, it has to first attach to the target. 111 | * One way to do it is to connect to Chrome via web socket using [ChromeDPClient.webSocket] and then 112 | * using [BrowserSession.attachToTarget]. 113 | * 114 | * However, most of the time, targets don't already exist, so it's easier to just create a new page 115 | * using [BrowserSession.newPage] and then interact with it through the returned [PageSession]. 116 | */ 117 | @Serializable 118 | data class ChromeDPTarget( 119 | val id: String, 120 | val title: String, 121 | val type: String, 122 | val description: String, 123 | val url: String, 124 | val devtoolsFrontendUrl: String, 125 | /** 126 | * The web socket URL to use with [chromeWebSocket] to connect via the debugger to this target. 127 | */ 128 | val webSocketDebuggerUrl: String, 129 | val faviconUrl: String? = null, 130 | ) 131 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/KtorClientExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.plugins.websocket.* 5 | import io.ktor.websocket.* 6 | import org.hildan.chrome.devtools.protocol.* 7 | import org.hildan.chrome.devtools.sessions.* 8 | import kotlin.coroutines.CoroutineContext 9 | import kotlin.coroutines.EmptyCoroutineContext 10 | 11 | /** 12 | * Connects to the Chrome debugger at the given [wsOrHttpUrl] (either web socket or HTTP), opening a [BrowserSession]. 13 | * 14 | * Note that `this` [HttpClient] must have the [WebSockets] plugin installed, even if [wsOrHttpUrl] is an HTTP URL. 15 | * 16 | * * if [wsOrHttpUrl] is a `ws://` or `wss://` URL, this function directly connects to the browser target via the web socket. 17 | * It should be something like `ws://localhost:9222/devtools/browser/b0b8a4fb-bb17-4359-9533-a8d9f3908bd8`. 18 | * If you're using services like [Browserless's Docker image](https://docs.browserless.io/baas/docker/quickstart), 19 | * you might have a simpler URL like `ws://localhost:3000` or `ws://localhost:3000?token=6R0W53R135510`. 20 | * 21 | * * if [wsOrHttpUrl] is an `http://` or `https://` URL, this function finds the web socket debugger URL via 22 | * the HTTP API, and then uses it to open the web socket to the Chrome debugger. 23 | * 24 | * If you have access to the web socket URL, it is preferable to use that one to avoid the extra hop. 25 | * 26 | * The returned [BrowserSession] only provides a limited subset of the possible operations, because it is 27 | * attached to the default *browser* target, not a *page* target. 28 | * To create a new page (tab), use [newPage] and then interact with it through the returned [PageSession]. 29 | * Refer to the documentation of [BrowserSession] for more info. 30 | * 31 | * The caller of this method is responsible for closing the web socket after use by calling [BrowserSession.close], 32 | * or using the auto-close capabilities via [use]. 33 | * Because all child sessions of the returned [BrowserSession] use the same underlying web socket connection, 34 | * calling [ChildSession.close] or [use] on a derived session doesn't close the connection (to avoid undesirable 35 | * interactions between child sessions). 36 | * 37 | * @param wsOrHttpUrl the web socket URL to use to connect, or the HTTP URL to use to find the web socket URL 38 | * @param sessionContext a custom [CoroutineContext] for the coroutines used in the Chrome session to process events 39 | */ 40 | suspend fun HttpClient.connectChromeDebugger( 41 | wsOrHttpUrl: String, 42 | sessionContext: CoroutineContext = EmptyCoroutineContext, 43 | ): BrowserSession { 44 | val wsUrl = when { 45 | wsOrHttpUrl.startsWith("ws://") || wsOrHttpUrl.startsWith("wss://") -> wsOrHttpUrl 46 | wsOrHttpUrl.startsWith("http://") || wsOrHttpUrl.startsWith("https://") -> { 47 | ChromeDP.httpApi(wsOrHttpUrl, httpClient = this).version().webSocketDebuggerUrl 48 | } 49 | else -> throw IllegalArgumentException("Unsupported URL scheme in $wsOrHttpUrl (please use ws, wss, http, or https)") 50 | } 51 | return chromeWebSocket(wsUrl, sessionContext) 52 | } 53 | 54 | /** 55 | * Connects to the Chrome debugger at the given [webSocketDebuggerUrl]. 56 | * 57 | * Note that `this` [HttpClient] must have the [WebSockets] plugin installed. 58 | * 59 | * This function expects a *web socket* URL (not HTTP). It should be something like 60 | * ``` 61 | * ws://localhost:9222/devtools/browser/b0b8a4fb-bb17-4359-9533-a8d9f3908bd8 62 | * ``` 63 | * If you're using services like [Browserless's Docker image](https://docs.browserless.io/baas/docker/quickstart), 64 | * you might have a simpler URL like `ws://localhost:3000` or `ws://localhost:3000?token=6R0W53R135510`. 65 | * 66 | * If you only have the debugger's HTTP URL at hand (e.g. `http://localhost:9222`), use [ChromeDP.httpApi] instead, 67 | * and then connect to the web socket using [ChromeDPHttpApi.webSocket]. 68 | * 69 | * The returned [BrowserSession] only provides a limited subset of the possible operations, because it is 70 | * attached to the default *browser* target, not a *page* target. 71 | * To create a new page (tab), use [newPage] and then interact with it through the returned [PageSession]. 72 | * Refer to the documentation of [BrowserSession] for more info. 73 | * 74 | * The caller of this method is responsible for closing the web socket after use by calling [BrowserSession.close], 75 | * or using the auto-close capabilities via [use]. 76 | * Because all child sessions of the returned [BrowserSession] use the same underlying web socket connection, 77 | * calling [ChildSession.close] or [use] on a derived session doesn't close the connection (to avoid undesirable 78 | * interactions between child sessions). 79 | */ 80 | internal suspend fun HttpClient.chromeWebSocket( 81 | webSocketDebuggerUrl: String, 82 | sessionContext: CoroutineContext = EmptyCoroutineContext, 83 | ): BrowserSession { 84 | require(webSocketDebuggerUrl.startsWith("ws://") || webSocketDebuggerUrl.startsWith("wss://")) { 85 | "The web socket API requires a 'ws://' or 'wss://' URL, but got $webSocketDebuggerUrl." 86 | } 87 | val webSocketSession = webSocketSession(webSocketDebuggerUrl) 88 | return try { 89 | webSocketSession.chromeDp(sessionContext).withSession(sessionId = null).asBrowserSession() 90 | } catch (e: Exception) { 91 | // the caller won't have the opportunity to clean this up if any of these conversion/wrapping operations fails 92 | webSocketSession.close() 93 | throw e 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/names/ExternalDeclarations.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.names 2 | 3 | import com.squareup.kotlinpoet.AnnotationSpec 4 | import com.squareup.kotlinpoet.ClassName 5 | import com.squareup.kotlinpoet.MemberName 6 | import com.squareup.kotlinpoet.MemberName.Companion.member 7 | import kotlinx.serialization.ExperimentalSerializationApi 8 | import kotlinx.serialization.KeepGeneratedSerializer 9 | import kotlinx.serialization.SerialName 10 | import kotlinx.serialization.Serializable 11 | import org.hildan.chrome.devtools.protocol.json.* 12 | 13 | const val ROOT_PACKAGE_NAME = "org.hildan.chrome.devtools" 14 | 15 | /** 16 | * Represents the naming contract for declarations that are used from non-generated code. 17 | */ 18 | object ExtDeclarations { 19 | 20 | const val sessionsPackage = "$ROOT_PACKAGE_NAME.sessions" 21 | private const val targetsPackage = "$ROOT_PACKAGE_NAME.targets" 22 | private const val protocolPackage = "$ROOT_PACKAGE_NAME.protocol" 23 | 24 | val chromeDPSession = ClassName(protocolPackage, "ChromeDPSession") 25 | val sessionRequestExtension = MemberName(protocolPackage, "request") 26 | val sessionTypedEventsExtension = MemberName(protocolPackage, "typedEvents") 27 | 28 | val experimentalChromeApi = ClassName(protocolPackage, "ExperimentalChromeApi") 29 | 30 | val fcEnumSerializer = ClassName(protocolPackage, "FCEnumSerializer") 31 | 32 | val allDomainsTargetInterface = ClassName(targetsPackage, "AllDomainsTarget") 33 | val allDomainsTargetImplementation = ClassName(targetsPackage, "UberTarget") 34 | 35 | const val sessionsFileName = "ChildSessions" 36 | const val sessionAdaptersFileName = "ChildSessionAdapters" 37 | val childSessionInterface = ClassName(sessionsPackage, "ChildSession") 38 | val childSessionUnsafeFun = childSessionInterface.member("unsafe") 39 | 40 | fun targetInterface(target: TargetType): ClassName = ClassName(targetsPackage, "${target.kotlinName}Target") 41 | fun sessionInterface(target: TargetType): ClassName = ClassName(sessionsPackage, "${target.kotlinName}Session") 42 | fun sessionAdapter(target: TargetType): ClassName = ClassName(sessionsPackage, "${target.kotlinName}SessionAdapter") 43 | } 44 | 45 | object Annotations { 46 | 47 | val serializable = AnnotationSpec.builder(Serializable::class).build() 48 | 49 | fun serialName(name: String) = AnnotationSpec.builder(SerialName::class).addMember("%S", name).build() 50 | 51 | fun jsName(name: String) = AnnotationSpec.builder(ClassName("kotlin.js", "JsName")).addMember("%S", name).build() 52 | 53 | fun serializableWith(serializerClass: ClassName) = AnnotationSpec.builder(Serializable::class) 54 | .addMember("with = %T::class", serializerClass) 55 | .build() 56 | 57 | val jvmOverloads = AnnotationSpec.builder(JvmOverloads::class).build() 58 | 59 | val deprecatedChromeApi = AnnotationSpec.builder(Deprecated::class) 60 | .addMember("message = \"Deprecated in the Chrome DevTools protocol\"") 61 | .build() 62 | 63 | val experimentalChromeApi = AnnotationSpec.builder(ExtDeclarations.experimentalChromeApi).build() 64 | 65 | /** 66 | * Annotation to suppress common warnings in generated files. 67 | */ 68 | val suppressWarnings = suppress( 69 | // because not all files need all these suppressions 70 | "KotlinRedundantDiagnosticSuppress", 71 | // necessary because public keyword cannot be removed 72 | "RedundantVisibilityModifier", 73 | // the warning occurs if a deprecated function uses a deprecated type as parameter type 74 | "DEPRECATION", 75 | // for data classes with params of experimental types, the warning doesn't go away by 76 | // annotating the relevant property/constructor-arg with experimental annotation. The whole class/constructor 77 | // would need to be annotated as experimental, which is not desirable 78 | "OPT_IN_USAGE", 79 | // we add @SerializableWith on each sub-object in forward-compatible enum interfaces to avoid issues when using 80 | // the serializers of the subtypes directly. The serialization plugin complains because we're using the parent 81 | // interface serializer on each subtype instead of a KSerializer, which is technically not safe in 82 | // general. We accept the tradeoff in our case, which is that this will throw ClassCaseException: 83 | // val value: AXPropertyName.level = Json.decodeFromString("\"url\"") 84 | "SERIALIZER_TYPE_INCOMPATIBLE", 85 | ) 86 | 87 | @Suppress("SameParameterValue") 88 | private fun suppress(vararg warningTypes: String) = AnnotationSpec.builder(Suppress::class) 89 | .addMember(format = warningTypes.joinToString { "%S" }, *warningTypes) 90 | .build() 91 | } 92 | 93 | object DocUrls { 94 | 95 | private const val docsBaseUrl = "https://chromedevtools.github.io/devtools-protocol/tot" 96 | 97 | fun domain(domainName: String) = "$docsBaseUrl/$domainName" 98 | 99 | fun type(domainName: String, typeName: String) = docElementUrl(domainName, "type", typeName) 100 | 101 | fun command(domainName: String, commandName: String) = docElementUrl(domainName, "method", commandName) 102 | 103 | fun event(domainName: String, eventName: String) = docElementUrl(domainName, "event", eventName) 104 | 105 | private fun docElementUrl(domainName: String, elementType: String, elementName: String) = 106 | "${domain(domainName)}/#$elementType-$elementName" 107 | } 108 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/DomainGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.* 4 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy 5 | import kotlinx.serialization.DeserializationStrategy 6 | import org.hildan.chrome.devtools.protocol.model.ChromeDPDomain 7 | import org.hildan.chrome.devtools.protocol.model.ChromeDPEvent 8 | import org.hildan.chrome.devtools.protocol.names.Annotations 9 | import org.hildan.chrome.devtools.protocol.names.ExtDeclarations 10 | 11 | private const val SESSION_PROP = "session" 12 | private const val DESERIALIZERS_PROP = "deserializersByEventName" 13 | 14 | private val deserializerClassName = DeserializationStrategy::class.asClassName() 15 | private val serializerFun = MemberName("kotlinx.serialization", "serializer") 16 | private val coroutineFlowClass = ClassName("kotlinx.coroutines.flow", "Flow") 17 | 18 | private fun mapOfDeserializers(eventsSealedClassName: ClassName): ParameterizedTypeName { 19 | val deserializerClass = deserializerClassName.parameterizedBy(eventsSealedClassName) 20 | return MAP.parameterizedBy(String::class.asTypeName(), deserializerClass) 21 | } 22 | 23 | fun ChromeDPDomain.createDomainFileSpec(): FileSpec = 24 | FileSpec.builder(packageName = names.packageName, fileName = names.filename).apply { 25 | addAnnotation(Annotations.suppressWarnings) 26 | commands.forEach { cmd -> 27 | // We don't need to create the input type all the time for backwards compatiblity, because it never 28 | // happened that all parameters of a command were removed. Therefore, we will never "drop" an exiting 29 | // input type. Note that when we generate methods with such input type, we also always have an overload 30 | // without it (unless there are now mandatory parameters of course), so this ensures compatibility in case 31 | // the command didn't have any parameters in the past and only has optional ones now. 32 | if (cmd.parameters.isNotEmpty()) { 33 | addType(cmd.createInputTypeSpec()) 34 | } 35 | // we always create the output type for forwards/backwards binary compatibility of command methods 36 | addType(cmd.createOutputTypeSpec()) 37 | } 38 | addType(createDomainClass()) 39 | }.build() 40 | 41 | private fun ChromeDPDomain.createDomainClass(): TypeSpec = TypeSpec.classBuilder(names.domainClassName).apply { 42 | addKDocAndStabilityAnnotations(element = this@createDomainClass) 43 | primaryConstructor(FunSpec.constructorBuilder() 44 | .addModifiers(KModifier.INTERNAL) 45 | .addParameter(SESSION_PROP, ExtDeclarations.chromeDPSession) 46 | .build()) 47 | addProperty(PropertySpec.builder(SESSION_PROP, ExtDeclarations.chromeDPSession) 48 | .addModifiers(KModifier.PRIVATE) 49 | .initializer(SESSION_PROP) 50 | .build()) 51 | if (events.isNotEmpty()) { 52 | addAllEventsFunction(this@createDomainClass) 53 | events.forEach { event -> 54 | addFunction(event.toSubscribeFunctionSpec()) 55 | addFunction(event.toLegacySubscribeFunctionSpec()) 56 | } 57 | } 58 | commands.forEach { cmd -> 59 | if (cmd.parameters.isNotEmpty()) { 60 | addFunction(cmd.toFunctionSpecWithParams(SESSION_PROP)) 61 | addFunction(cmd.toDslFunctionSpec()) 62 | } else { 63 | addFunction(cmd.toNoArgFunctionSpec(SESSION_PROP)) 64 | } 65 | } 66 | }.build() 67 | 68 | private fun ChromeDPEvent.toSubscribeFunctionSpec(): FunSpec = 69 | FunSpec.builder(names.flowMethodName).apply { 70 | addKDocAndStabilityAnnotations(element = this@toSubscribeFunctionSpec) 71 | returns(coroutineFlowClass.parameterizedBy(names.eventTypeName)) 72 | addStatement("return %N.%M(%S)", SESSION_PROP, ExtDeclarations.sessionTypedEventsExtension, names.fullEventName) 73 | }.build() 74 | 75 | private fun ChromeDPEvent.toLegacySubscribeFunctionSpec(): FunSpec = 76 | FunSpec.builder(names.legacyMethodName).apply { 77 | addAnnotation(AnnotationSpec.builder(Deprecated::class) 78 | .addMember("message = \"Events subscription methods were renamed with the -Events suffix.\"") 79 | .addMember("replaceWith = ReplaceWith(\"%N()\")", names.flowMethodName) 80 | .build()) 81 | returns(coroutineFlowClass.parameterizedBy(names.eventTypeName)) 82 | addStatement("return %N()", names.flowMethodName) 83 | }.build() 84 | 85 | private fun TypeSpec.Builder.addAllEventsFunction(domain: ChromeDPDomain) { 86 | addProperty(PropertySpec.builder(DESERIALIZERS_PROP, mapOfDeserializers(domain.names.eventsParentClassName)) 87 | .addKdoc("Mapping between events and their deserializer.") 88 | .addModifiers(KModifier.PRIVATE) 89 | .initializer(domain.deserializersMapCodeBlock()) 90 | .build()) 91 | addFunction(FunSpec.builder("events") 92 | .addKdoc("Subscribes to all events related to this domain.") 93 | .returns(coroutineFlowClass.parameterizedBy(domain.names.eventsParentClassName)) 94 | .addCode("return %N.%M(%N)", SESSION_PROP, ExtDeclarations.sessionTypedEventsExtension, DESERIALIZERS_PROP) 95 | .build()) 96 | } 97 | 98 | private fun ChromeDPDomain.deserializersMapCodeBlock(): CodeBlock = CodeBlock.builder().apply { 99 | add("mapOf(\n") 100 | events.forEach { e -> 101 | add("%S to %M<%T>(),\n", e.names.fullEventName, serializerFun, e.names.eventTypeName) 102 | } 103 | add(")") 104 | }.build() 105 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/sessions/GotoExtension.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.sessions 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | import org.hildan.chrome.devtools.domains.page.* 6 | import org.hildan.chrome.devtools.protocol.* 7 | 8 | /** 9 | * Navigates the current page to the provided [url], and suspends until the configured completion events are fired. 10 | * By default, this function suspends until the [GotoCompletionEvent.Load] event is fired. 11 | * 12 | * The completion events and the navigation request can be configured via the [configure] parameter. 13 | * 14 | * This function throws [NavigationFailed] if the navigation response has an error, instead of waiting forever for an 15 | * event that will never come. 16 | * 17 | * If there is no failure, and yet the specified events never fire, this function may hang (there is no built-in 18 | * timeout). You can use the coroutine's built-in [withTimeout] function if you need to stop waiting at some point. 19 | */ 20 | suspend fun PageSession.goto( 21 | url: String, 22 | configure: GotoConfigBuilder.() -> Unit = {}, 23 | ) { 24 | val config = GotoConfigBuilder().apply(configure) 25 | val navigateRequest = config.buildNavigateRequest(url) 26 | 27 | page.enable() 28 | coroutineScope { 29 | config.completionEvents.forEach { completionEvent -> 30 | launch(start = CoroutineStart.UNDISPATCHED) { 31 | completionEvent.await(this@goto, navigateRequest) 32 | } 33 | } 34 | val response = page.navigate(navigateRequest) 35 | if (response.errorText != null) { 36 | throw NavigationFailed(navigateRequest, response) 37 | } 38 | // Here, coroutineScope will wait for all launched coroutines to complete 39 | } 40 | } 41 | 42 | @OptIn(ExperimentalChromeApi::class) 43 | private fun GotoConfigBuilder.buildNavigateRequest(url: String): NavigateRequest = 44 | NavigateRequest.Builder(url = url).apply { 45 | frameId = this@buildNavigateRequest.frameId 46 | referrer = this@buildNavigateRequest.referrer 47 | referrerPolicy = this@buildNavigateRequest.referrerPolicy 48 | transitionType = this@buildNavigateRequest.transitionType 49 | }.build() 50 | 51 | /** 52 | * Defines properties that can be customized when navigating a page using [goto]. 53 | */ 54 | class GotoConfigBuilder internal constructor() { 55 | 56 | /** 57 | * Referrer URL. 58 | */ 59 | var referrer: String? = null 60 | 61 | /** 62 | * Referrer-policy used for the navigation. 63 | */ 64 | @ExperimentalChromeApi 65 | var referrerPolicy: ReferrerPolicy? = null 66 | 67 | /** 68 | * Intended transition type. 69 | */ 70 | var transitionType: TransitionType? = null 71 | 72 | /** 73 | * Frame id to navigate. If not specified, navigates the top frame. 74 | */ 75 | var frameId: FrameId? = null 76 | 77 | internal var completionEvents: List = listOf(GotoCompletionEvent.Load) 78 | 79 | /** 80 | * Defines the events that [goto] should wait for before considering the navigation complete and resuming. 81 | * 82 | * By default, [goto] awaits the [GotoCompletionEvent.Load] event. 83 | * 84 | * Calling this function without arguments disable all completion events and makes [goto] complete immediately 85 | * after the `navigate` request completes (without waiting for anything). 86 | */ 87 | fun waitFor(vararg completionEvents: GotoCompletionEvent) { 88 | this.completionEvents = completionEvents.toList() 89 | } 90 | } 91 | 92 | /** 93 | * An event that a [goto] call can await before considering the navigation complete. 94 | */ 95 | // Using a class instead of enum here in case new events are added and require configuration. 96 | // This abstract class is not sealed so we can safely add new completion event types without breaking user code. 97 | abstract class GotoCompletionEvent { 98 | 99 | internal abstract suspend fun await(session: PageSession, navigateRequest: NavigateRequest) 100 | 101 | /** 102 | * Fired when the whole page has loaded, including all dependent resources such as images, stylesheets, scripts, 103 | * and iframes. 104 | */ 105 | object Load : GotoCompletionEvent() { 106 | override suspend fun await(session: PageSession, navigateRequest: NavigateRequest) { 107 | session.page.loadEventFiredEvents().first() 108 | } 109 | } 110 | 111 | /** 112 | * Fired when the specific frame has stopped loading. 113 | */ 114 | @Suppress("unused") 115 | object FrameStoppedLoading : GotoCompletionEvent() { 116 | @OptIn(ExperimentalChromeApi::class) 117 | override suspend fun await(session: PageSession, navigateRequest: NavigateRequest) { 118 | session.page.frameStoppedLoadingEvents() 119 | .first { it.frameId == (navigateRequest.frameId ?: session.metaData.targetId) } 120 | } 121 | } 122 | 123 | /** 124 | * Fired when the browser fully loaded the HTML, and the DOM tree is built, but external resources like pictures 125 | * and stylesheets may not have been loaded yet. 126 | */ 127 | @Suppress("unused") 128 | object DomContentLoaded : GotoCompletionEvent() { 129 | override suspend fun await(session: PageSession, navigateRequest: NavigateRequest) { 130 | session.page.domContentEventFiredEvents().first() 131 | } 132 | } 133 | } 134 | 135 | /** 136 | * Thrown to indicate that the navigation to a page has failed. 137 | */ 138 | class NavigationFailed( 139 | val request: NavigateRequest, 140 | val response: NavigateResponse, 141 | ) : Exception("Navigation to ${request.url} has failed: ${response.errorText}") 142 | -------------------------------------------------------------------------------- /protocol-definition/README.md: -------------------------------------------------------------------------------- 1 | # Protocol definitions 2 | 3 | This README describes how the files in this directory are updated or maintained. 4 | 5 | ## Automatic protocol definition updates 6 | 7 | The `browser_protocol.json` and `js_protocol.json` files are the official protocol definitions from the "latest" (a.k.a 8 | ["tip-of-tree"](https://chromedevtools.github.io/devtools-protocol/tot/)) version of the protocol. 9 | 10 | The `version.txt` file contains the Chromium revision that produced these JSON protocol definitions. 11 | It's a monotonic version number referring to the chromium master commit position. 12 | 13 | These files are automatically and regularly updated based on the state of the 14 | [ChromeDevTools/devtools-protocol](https://github.com/ChromeDevTools/devtools-protocol) repository, via the 15 | [update-protocol.yml](../.github/workflows/update-protocol.yml) GitHub Actions workflow: 16 | 17 | * The JSON definitions are taken as-is from the [json](https://github.com/ChromeDevTools/devtools-protocol/tree/master/json) directory. 18 | * The Chromium revision is extracted from the npm version defined in the 19 | [package.json](https://github.com/ChromeDevTools/devtools-protocol/blob/master/package.json). 20 | 21 | For more details, see the corresponding [Gradle task](..%2FbuildSrc%2Fsrc%2Fmain%2Fkotlin%2FUpdateProtocolDefinitionsTask.kt) from `buildSrc`. 22 | This task can also be run locally using `./gradlew updateProtocolDefinitions`. 23 | 24 | ## Manual updates of target types 25 | 26 | The protocol files are missing important information that is necessary for Chrome DevTools Kotlin's codegen: 27 | 28 | * the existing target types 29 | * the domains supported by each target type 30 | 31 | This information has to be inferred from the Chromium sources, and it is stored in the `target_types.json` so it can be 32 | consumed by the code generator. 33 | 34 | This file is manually maintained at the moment, as the process is a bit tricky to automate. 35 | 36 | ### Background information 37 | 38 | There are 2 kinds of "target types": 39 | 40 | * the **"protocol" target types** are the ones that are used in the protocol for the 41 | [TargetInfo.type](https://chromedevtools.github.io/devtools-protocol/tot/Target/#type-TargetInfo) field. 42 | They define real-life types of targets (`page`, `iframe`, `worker`), but they don't all differ in their capabilities on the server. 43 | For this reason, they don't correspond one-to-one to Kotlin interfaces in this library. 44 | * the **"Chromium" target types** (or "DevTools agent host" types) directly relate to Chromium's implementation, 45 | which means they represent the target types based on their capabilities (the domains they support). 46 | This is why they are represented by `*Target` interfaces in the Kotlin code generation of this library. 47 | Each of those can handle one or more "protocol" target types. For example, the Chromium target type "RenderFrame" is the 48 | implementation handling the `page`, `iframe` and `webview` target types from the protocol. 49 | 50 | The list of "protocol" target types is effectively defined by the set of `const char DevToolsAgentHost::kTypeX[]` 51 | [constants in Chromium's source](https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc;l=126-140). 52 | 53 | The DevTools agent host types are defined in the `_devtools_agent_host.cc` source files. 54 | 55 | ### Update procedure 56 | 57 | Here are the steps performed to get the information from the Chromium source code into `target_types.json`: 58 | 59 | 1. Discover all Chromium target types by looking at [files named `*_devtools_agent_host.cc`](https://source.chromium.org/search?q=f:devtools_agent_host.cc). 60 | Each of them contains a subclass of `DevToolsAgentHostImpl`, named after the filename. 61 | Almost all of those should have a corresponding entry in `target_types.json` (but read on for some exceptions). 62 | 63 | 2. The `::GetType()` method of the agent host type returns the protocol target type represented by this 64 | Chromium target type. Sometimes the code can return different values. The list of all possible return values should 65 | added to `supportedCdpTargets`. 66 | If this `GetType` method just delegates to something else, ignore this whole agent host type (it's probably just a wrapper). 67 | 68 | 3. You can check the [list of constants](https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc?q=%22const%20char%20DevToolsAgentHost::kType%22) 69 | defining the protocol target types, and verify you got all of them covered (except maybe "other"). 70 | 71 | 4. Search for [domain handler declarations in Chromium's source](https://source.chromium.org/search?q=%22session-%3ECreateAndAddHandler%22%20f:devtools&ss=chromium). 72 | Each `session->CreateAndAddHandler();` match in a `*_devtools_agent_host.cc` file represents 73 | a domain supported by this target type (watch out for macros that only include domains conditionally). 74 | The domain name is the prefix before `Handler` in the handler type. 75 | Add all supported domains to the `supportedDomainsInChromium` list of the target type in `target_types.json`. 76 | 77 | 5. The integration test for the schema should also detect additional domains supported by the Page session 78 | (`RenderFrame` host agent type). Make sure the missing supported domains are added to this target type in 79 | `target_types.json`, as the `additionalSupportedDomains` property. 80 | Note: as of now, it's still unclear *how* these additional domains are supported. Their implementation seems to be 81 | in a different part of the code, but I'm not sure where they are linked to the agent host types. 82 | 83 | 6. Each target type also gets a custom name for use in Kotlin code. This name usually matches the agent host type name 84 | or the main protocol target type supported by the Chromium target type. Pragmatically, `Page` was used instead of 85 | `RenderFrame` for clarity. 86 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/domains/dom/DOMExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.domains.dom 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlin.time.Duration 5 | import kotlin.time.Duration.Companion.milliseconds 6 | 7 | /** 8 | * A CSS selector string, as [defined by the W3C](https://www.w3schools.com/cssref/css_selectors.asp). 9 | */ 10 | typealias CssSelector = String 11 | 12 | /** 13 | * Retrieves the root [Node] of the current document. 14 | */ 15 | suspend fun DOMDomain.getDocumentRoot(): Node = getDocument().root 16 | 17 | /** 18 | * Retrieves the ID of the root node of the current document. 19 | */ 20 | suspend fun DOMDomain.getDocumentRootNodeId(): NodeId = getDocumentRoot().nodeId 21 | 22 | /** 23 | * Retrieves the ID of the node corresponding to the given [selector], or null if not found. 24 | * 25 | * Note that the returned [NodeId] cannot really be used to retrieve actual node information, and this is apparently 26 | * [by design of the DOM domain](https://github.com/ChromeDevTools/devtools-protocol/issues/20). 27 | * It can be used to perform other CDP commands that require a [NodeId], though. 28 | */ 29 | suspend fun DOMDomain.findNodeBySelector(selector: CssSelector): NodeId? = 30 | querySelectorOnNode(getDocumentRootNodeId(), selector) 31 | 32 | private suspend fun DOMDomain.querySelectorOnNode(nodeId: NodeId, selector: CssSelector): NodeId? { 33 | // for some reason, the API returns nodeId == 0 when not found 34 | return querySelector(nodeId, selector).nodeId.takeIf { it != 0 } 35 | } 36 | 37 | /** 38 | * Retrieves the ID of the node corresponding to the given [selector], and retries until there is a match using the 39 | * given [pollingPeriod]. 40 | * 41 | * This method may suspend forever if the [selector] never matches any node. 42 | * The caller is responsible for using [withTimeout][kotlinx.coroutines.withTimeout] or similar cancellation mechanisms 43 | * around calls to this method if handling this case is necessary. 44 | * 45 | * Note that the returned [NodeId] cannot really be used to retrieve actual node information, and this is apparently 46 | * [by design of the DOM domain](https://github.com/ChromeDevTools/devtools-protocol/issues/20). 47 | * It can be used to perform other CDP commands that require a [NodeId], though. 48 | */ 49 | suspend fun DOMDomain.awaitNodeBySelector(selector: CssSelector, pollingPeriod: Duration = 200.milliseconds): NodeId { 50 | while (true) { 51 | // it looks like we do need to get a new document at each poll otherwise we may not see the new nodes 52 | val nodeId = findNodeBySelector(selector) 53 | if (nodeId != null) { 54 | return nodeId 55 | } 56 | delay(pollingPeriod) 57 | } 58 | } 59 | 60 | /** 61 | * Waits until the given [selector] matches no node in the DOM. Inspects the DOM every [pollingPeriod]. 62 | * 63 | * This method may suspend forever if the [selector] keeps matching at least one node. 64 | * The caller is responsible for using [withTimeout][kotlinx.coroutines.withTimeout] or similar cancellation mechanisms 65 | * around calls to this method if handling this case is necessary. 66 | */ 67 | suspend fun DOMDomain.awaitNodeAbsentBySelector(selector: CssSelector, pollingPeriod: Duration = 200.milliseconds) { 68 | while (true) { 69 | // it looks like we do need to get a new document at each poll otherwise we may not see the new nodes 70 | findNodeBySelector(selector) ?: return 71 | delay(pollingPeriod) 72 | } 73 | } 74 | 75 | /** 76 | * Retrieves the ID of the node corresponding to the given [selector], or throw an exception if not found. 77 | * 78 | * Note that the returned [NodeId] cannot really be used to retrieve actual node information, and this is apparently 79 | * [by design of the DOM domain](https://github.com/ChromeDevTools/devtools-protocol/issues/20). 80 | * It can be used to perform other CDP commands that require a [NodeId], though. 81 | */ 82 | suspend fun DOMDomain.getNodeBySelector(selector: CssSelector): NodeId = 83 | findNodeBySelector(selector) ?: error("DOM node not found with selector: $selector") 84 | 85 | /** 86 | * Moves the focus to the node corresponding to the given [selector], or null if not found. 87 | */ 88 | suspend fun DOMDomain.focusNodeBySelector(selector: CssSelector) { 89 | focus { 90 | nodeId = findNodeBySelector(selector) ?: error("Cannot focus: no node found using selector '$selector'") 91 | } 92 | } 93 | 94 | /** 95 | * Gets the attributes of the node corresponding to the given [nodeSelector], or null if the selector didn't match 96 | * any node. 97 | */ 98 | suspend fun DOMDomain.getTypedAttributes(nodeSelector: CssSelector): DOMAttributes? = 99 | findNodeBySelector(nodeSelector)?.let { nodeId -> getTypedAttributes(nodeId) } 100 | 101 | /** 102 | * Gets the attributes of the node corresponding to the given [nodeId]. 103 | */ 104 | suspend fun DOMDomain.getTypedAttributes(nodeId: NodeId): DOMAttributes = 105 | getAttributes(nodeId).attributes.asDOMAttributes() 106 | 107 | /** 108 | * Gets the value of the attribute [attributeName] of the node corresponding to the given [nodeSelector], or null if 109 | * the selector didn't match any node or if the attribute was not present on the node. 110 | */ 111 | suspend fun DOMDomain.getAttributeValue(nodeSelector: CssSelector, attributeName: String): String? = 112 | getTypedAttributes(nodeSelector)?.get(attributeName) 113 | 114 | /** 115 | * Gets the value of the attribute [attributeName] of the node corresponding to the given [nodeId], or null if 116 | * the attribute was not present on the node. 117 | */ 118 | suspend fun DOMDomain.getAttributeValue(nodeId: NodeId, attributeName: String): String? = 119 | getTypedAttributes(nodeId)[attributeName] 120 | 121 | /** 122 | * Sets the attribute of the given [name] to the given [value] on the node corresponding to the given [nodeSelector]. 123 | * Throws an exception if the selector didn't match any node. 124 | */ 125 | suspend fun DOMDomain.setAttributeValue(nodeSelector: CssSelector, name: String, value: String) { 126 | setAttributeValue(nodeId = getNodeBySelector(nodeSelector), name, value) 127 | } 128 | 129 | /** 130 | * Returns boxes for the node corresponding to the given [selector], or null if the selector didn't match any node. 131 | */ 132 | suspend fun DOMDomain.getBoxModel(selector: CssSelector): BoxModel? = findNodeBySelector(selector)?.let { getBoxModel(it) } 133 | 134 | /** 135 | * Returns boxes for the node corresponding to the given [nodeId]. 136 | * 137 | * [Official doc](https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getBoxModel) 138 | */ 139 | suspend fun DOMDomain.getBoxModel(nodeId: NodeId): BoxModel = getBoxModel { this.nodeId = nodeId }.model 140 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/TargetGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.* 4 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy 5 | import org.hildan.chrome.devtools.protocol.json.TargetType 6 | import org.hildan.chrome.devtools.protocol.model.ChromeDPDomain 7 | import org.hildan.chrome.devtools.protocol.names.Annotations 8 | import org.hildan.chrome.devtools.protocol.names.ExtDeclarations 9 | 10 | private const val SESSION_ARG = "session" 11 | 12 | fun createTargetInterface(target: TargetType, domains: List): TypeSpec = 13 | TypeSpec.interfaceBuilder(ExtDeclarations.targetInterface(target)).apply { 14 | addKdoc(""" 15 | Represents the available domain APIs in ${target.kotlinName} targets. 16 | 17 | The subset of domains available for this target type is not strictly defined by the protocol. The 18 | subset provided in this interface is guaranteed to work on this target type. However, some 19 | domains might be missing in this interface while being effectively supported by the target. If 20 | this is the case, you can always use the [%T] interface instead. 21 | 22 | This interface is generated to match the latest Chrome DevToolsProtocol definitions. 23 | It is not stable for inheritance, as new properties can be added without major version bump when 24 | the protocol changes. It is however safe to use all non-experimental and non-deprecated domains 25 | defined here. The experimental and deprecation cycles of the protocol are reflected in this 26 | interface with the same guarantees. 27 | """.trimIndent(), ExtDeclarations.allDomainsTargetInterface) 28 | domains.forEach { 29 | addProperty(it.toPropertySpec()) 30 | } 31 | addType( 32 | TypeSpec.companionObjectBuilder() 33 | .addProperty(supportedDomainsProperty(domains)) 34 | .addProperty(supportedCdpTargetsProperty(target.supportedCdpTargets)) 35 | .build() 36 | ) 37 | }.build() 38 | 39 | fun createAllDomainsTargetInterface(allTargets: List, allDomains: List): TypeSpec = 40 | TypeSpec.interfaceBuilder(ExtDeclarations.allDomainsTargetInterface).apply { 41 | addKdoc(""" 42 | Represents a fictional (unsafe) target with access to all possible domain APIs in all targets. 43 | 44 | Every target supports only a subset of the protocol domains. Since these subsets are not clearly 45 | defined by the protocol definitions, the subset available in each target-specific interface is 46 | not strictly guaranteed to cover all the domains that are *effectively* supported by a target. 47 | 48 | This interface is an escape hatch to provide access to all domain APIs in case some domain is 49 | missing in the target-specific interface. It should be used with care, only when you know for 50 | sure that the domains you are using are effectively supported by the real attached target, 51 | otherwise you'll get runtime errors. 52 | 53 | This interface is generated to match the latest Chrome DevToolsProtocol definitions. 54 | It is not stable for inheritance, as new properties can be added without major version bump when 55 | the protocol changes. 56 | """.trimIndent()) 57 | 58 | allTargets.forEach { target -> 59 | addSuperinterface(ExtDeclarations.targetInterface(target)) 60 | } 61 | 62 | // All domains supported by at least one target will be implicitly brought by the corresponding superinterface, 63 | // but we also need to add properties for domains that are technically not supported by any target interface. 64 | val domainsPresentInATarget = allTargets.flatMapTo(mutableSetOf()) { it.supportedDomains } 65 | val danglingDomains = allDomains.filterNot { it.names.domainName in domainsPresentInATarget } 66 | danglingDomains.forEach { 67 | addProperty(it.toPropertySpec()) 68 | } 69 | addType( 70 | TypeSpec.companionObjectBuilder() 71 | .addProperty(supportedDomainsProperty(allDomains)) 72 | .build() 73 | ) 74 | }.build() 75 | 76 | private fun supportedDomainsProperty(domains: List): PropertySpec = 77 | PropertySpec.builder("supportedDomains", SET.parameterizedBy(String::class.asTypeName())).apply { 78 | addModifiers(KModifier.INTERNAL) 79 | val format = List(domains.size) { "%S" }.joinToString(separator = ", ", prefix = "setOf(", postfix = ")") 80 | initializer(format = format, *domains.map { it.names.domainName }.toTypedArray()) 81 | }.build() 82 | 83 | private fun supportedCdpTargetsProperty(cdpTargets: List): PropertySpec = 84 | PropertySpec.builder("supportedCdpTargets", SET.parameterizedBy(String::class.asTypeName())).apply { 85 | addModifiers(KModifier.INTERNAL) 86 | val format = List(cdpTargets.size) { "%S" }.joinToString(separator = ", ", prefix = "setOf(", postfix = ")") 87 | initializer(format = format, *cdpTargets.toTypedArray()) 88 | }.build() 89 | 90 | fun createAllDomainsTargetImpl(targetTypes: List, domains: List): TypeSpec = 91 | TypeSpec.classBuilder(ExtDeclarations.allDomainsTargetImplementation).apply { 92 | addKdoc("Implementation of all target interfaces by exposing all domain APIs") 93 | addModifiers(KModifier.INTERNAL) 94 | addSuperinterface(ExtDeclarations.allDomainsTargetInterface) 95 | targetTypes.forEach { 96 | addSuperinterface(ExtDeclarations.targetInterface(it)) 97 | } 98 | 99 | primaryConstructor( 100 | FunSpec.constructorBuilder() 101 | .addParameter(SESSION_ARG, ExtDeclarations.chromeDPSession) 102 | .build() 103 | ) 104 | 105 | domains.forEach { domain -> 106 | addProperty(domain.toPropertySpec { 107 | addModifiers(KModifier.OVERRIDE) 108 | delegate("lazy { %T(%N) }", domain.names.domainClassName, SESSION_ARG) 109 | }) 110 | } 111 | }.build() 112 | 113 | private fun ChromeDPDomain.toPropertySpec(configure: PropertySpec.Builder.() -> Unit = {}): PropertySpec = 114 | PropertySpec.builder(names.targetFieldName, names.domainClassName).apply { 115 | addKDocAndStabilityAnnotations(this@toPropertySpec) 116 | configure() 117 | }.build() 118 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/CommandGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.* 4 | import org.hildan.chrome.devtools.protocol.model.ChromeDPCommand 5 | import org.hildan.chrome.devtools.protocol.names.Annotations 6 | import org.hildan.chrome.devtools.protocol.names.ExtDeclarations 7 | 8 | internal fun ChromeDPCommand.createInputTypeSpec(): TypeSpec { 9 | return TypeSpec.classBuilder(names.inputTypeName).apply { 10 | addKdoc("Request object containing input parameters for the [%T.%N] command.", 11 | names.domain.domainClassName, 12 | names.methodName, 13 | ) 14 | commonCommandType(this@createInputTypeSpec) 15 | addModifiers(KModifier.DATA) 16 | addPrimaryConstructorProps(parameters) 17 | 18 | if (parameters.any { it.optional }) { 19 | addType(createInputBuilderType()) 20 | } 21 | }.build() 22 | } 23 | 24 | private fun ChromeDPCommand.createInputBuilderType(): TypeSpec { 25 | val cmd = this 26 | return TypeSpec.classBuilder(names.inputTypeBuilderName).apply { 27 | addKdoc( 28 | "A builder for [%T], which allows setting the optional parameters of the [%T.%N] command via a lambda.", 29 | names.inputTypeName, 30 | names.domain.domainClassName, 31 | names.methodName, 32 | ) 33 | val (optionalProps, mandatoryProps) = cmd.parameters.partition { it.optional } 34 | addPrimaryConstructorProps(mandatoryProps) 35 | addProperties(optionalProps.map { 36 | it.toPropertySpec { 37 | mutable() 38 | initializer("null") 39 | } 40 | }) 41 | 42 | val constructorCall = constructorCallTemplate(names.inputTypeName, cmd.parameters.map { it.name }) 43 | addFunction( 44 | FunSpec.builder("build") 45 | .returns(names.inputTypeName) 46 | .addNamedCode("return ${constructorCall.template}", constructorCall.namedArgsMapping) 47 | .build() 48 | ) 49 | }.build() 50 | } 51 | 52 | internal fun ChromeDPCommand.createOutputTypeSpec(): TypeSpec { 53 | val typeBuilder = if (returns.isEmpty()) { 54 | TypeSpec.objectBuilder(names.outputTypeName).apply { 55 | addKdoc("A dummy response object for the [%T.%N] command. This command doesn't return any result at " + 56 | "the moment, but this could happen in the future, or could have happened in the past. For forwards " + 57 | "and backwards compatibility of the command method, we still declare this class even without " + 58 | "properties.", 59 | names.domain.domainClassName, 60 | names.methodName, 61 | ) 62 | } 63 | } else { 64 | TypeSpec.classBuilder(names.outputTypeName).apply { 65 | addKdoc("Response type for the [%T.%N] command.", names.domain.domainClassName, names.methodName) 66 | addModifiers(KModifier.DATA) 67 | addPrimaryConstructorProps(returns) 68 | } 69 | } 70 | return typeBuilder.apply { 71 | commonCommandType(this@createOutputTypeSpec) 72 | }.build() 73 | } 74 | 75 | private fun TypeSpec.Builder.commonCommandType(chromeDPCommand: ChromeDPCommand) { 76 | addAnnotation(Annotations.serializable) 77 | if (chromeDPCommand.deprecated) { 78 | addAnnotation(Annotations.deprecatedChromeApi) 79 | } 80 | if (chromeDPCommand.experimental) { 81 | addAnnotation(Annotations.experimentalChromeApi) 82 | } 83 | } 84 | 85 | internal fun ChromeDPCommand.toNoArgFunctionSpec(sessionPropertyName: String): FunSpec = 86 | FunSpec.builder(names.methodName).apply { 87 | commonCommandFunction(command = this@toNoArgFunctionSpec) 88 | addStatement( 89 | "return %N.%M(%S, Unit)", 90 | sessionPropertyName, 91 | ExtDeclarations.sessionRequestExtension, 92 | names.fullCommandName, 93 | ) 94 | }.build() 95 | 96 | internal fun ChromeDPCommand.toFunctionSpecWithParams(sessionPropertyName: String): FunSpec = 97 | FunSpec.builder(names.methodName).apply { 98 | commonCommandFunction(command = this@toFunctionSpecWithParams) 99 | addKdoc( 100 | "\n\nNote: this function uses an input class, and constructing this class manually may lead to " + 101 | "incompatibilities if the class's constructor arguments change in the future. For maximum " + 102 | "compatibility, it is advised to use the overload of this function that directly takes the mandatory " + 103 | "parameters as arguments, and the optional ones from a configuration lambda." 104 | ) 105 | val inputArg = ParameterSpec.builder(name = "input", type = names.inputTypeName).build() 106 | addParameter(inputArg) 107 | addStatement( 108 | "return %N.%M(%S, %N)", 109 | sessionPropertyName, 110 | ExtDeclarations.sessionRequestExtension, 111 | names.fullCommandName, 112 | inputArg, 113 | ) 114 | }.build() 115 | 116 | internal fun ChromeDPCommand.toDslFunctionSpec(): FunSpec { 117 | val (optionalParams, mandatoryParams) = parameters.partition { it.optional } 118 | return FunSpec.builder(names.methodName).apply { 119 | commonCommandFunction(command = this@toDslFunctionSpec) 120 | addParameters(mandatoryParams.map { it.toParameterSpec() }) 121 | 122 | if (optionalParams.any()) { 123 | addAnnotation(Annotations.jvmOverloads) 124 | addModifiers(KModifier.INLINE) 125 | val initLambdaParam = optionalArgsBuilderLambdaParam(names.inputTypeBuilderName) 126 | addParameter(initLambdaParam) 127 | val builderConstructorCall = constructorCallTemplate(names.inputTypeBuilderName, mandatoryParams.map { it.name }) 128 | addNamedCode("val builder = ${builderConstructorCall.template}\n", builderConstructorCall.namedArgsMapping) 129 | addStatement("val input = builder.apply(%N).build()", initLambdaParam) 130 | } else { 131 | val constructorCall = constructorCallTemplate(names.inputTypeName, mandatoryParams.map { it.name }) 132 | addNamedCode("val input = ${constructorCall.template}\n", constructorCall.namedArgsMapping) 133 | } 134 | addStatement("return %N(input)", names.methodName) 135 | }.build() 136 | } 137 | 138 | private fun FunSpec.Builder.commonCommandFunction(command: ChromeDPCommand) { 139 | addKDocAndStabilityAnnotations(command) 140 | addModifiers(KModifier.SUSPEND) 141 | returns(command.names.outputTypeName) 142 | } 143 | 144 | private fun optionalArgsBuilderLambdaParam(builderTypeName: TypeName) = 145 | ParameterSpec.builder(name = "optionalArgs", type = lambdaTypeWithBuilderReceiver(builderTypeName)).apply { 146 | defaultValue("{}") 147 | }.build() 148 | 149 | private fun lambdaTypeWithBuilderReceiver(builderTypeName: TypeName) = LambdaTypeName.get( 150 | receiver = builderTypeName, 151 | returnType = Unit::class.asTypeName(), 152 | ) 153 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/org/hildan/chrome/devtools/sessions/Sessions.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.sessions 2 | 3 | import org.hildan.chrome.devtools.domains.target.* 4 | import org.hildan.chrome.devtools.targets.* 5 | 6 | /** 7 | * A Chrome DevTools debugging session, created when attaching to a target. 8 | */ 9 | interface ChromeSession { 10 | 11 | /** 12 | * Gives access to all domains of the protocol, regardless of the type of this session. 13 | * 14 | * This should only be used as a workaround when a domain is missing in the current typed API, but is known to be 15 | * available. 16 | * 17 | * If you need to use this method, please also open an issue so that the properly typed API is added: 18 | * https://github.com/joffrey-bion/chrome-devtools-kotlin/issues 19 | */ 20 | fun unsafe(): AllDomainsTarget 21 | 22 | /** 23 | * Closes the underlying web socket connection of this session, effectively closing every other session based on 24 | * the same web socket connection. This is equivalent to closing the entire browser session. 25 | */ 26 | suspend fun closeWebSocket() 27 | } 28 | 29 | /** 30 | * A browser session. This is the root session created when initially connecting to the browser's debugger. 31 | * 32 | * Such root session doesn't have the most useful APIs because it represents the connection to the browser target. 33 | * 34 | * To interact with more interesting targets, such as pages and workers, you need to first attach to that target, which 35 | * creates a child session of this session, which offers more APIs: 36 | * 37 | * * To create a new page/tab, use [BrowserSession.newPage] and then interact with it through the returned 38 | * [PageSession], which is a child session of the browser session and has many more APIs. 39 | * 40 | * * If you want to attach to an already existing target, use the [BrowserSession.target] domain to get information 41 | * about the target, and then use [BrowserSession.attachToTarget]. Note that you will only get a plain [ChildSession] 42 | * after this. Use one of the converters to convert this session to the proper type depending on the target type you 43 | * want to interact with. For example, if you're attaching to a worker, use [ChildSession.asWorkerSession] to get a 44 | * [WorkerSession], which offers the relevant domains for you. 45 | * 46 | */ 47 | interface BrowserSession : ChromeSession, BrowserTarget { 48 | 49 | /** 50 | * Creates a new [ChildSession] attached to the target with the given [targetId]. 51 | * The new session shares the same underlying web socket connection as this [BrowserSession]. 52 | * 53 | * Note that a [ChildSession] is a generic session that doesn't carry information about the exact target type, and 54 | * thus doesn't offer very useful APIs. It is meant to be converted to a more specific session type using one of the 55 | * converter extensions (`ChildSession.as*()`). 56 | * 57 | * For example, if you're attaching to a worker, use [ChildSession.asWorkerSession] to get a [WorkerSession], 58 | * which offers the relevant domains for you. 59 | * 60 | * **Note:** if you want to create a NEW page (or tab) and attach to it, use [newPage] instead. It will save you 61 | * the trouble of creating the target, handling its browser context, and dealing with the return type. 62 | * 63 | * @see newPage 64 | */ 65 | suspend fun attachToTarget(targetId: TargetID): ChildSession 66 | 67 | /** 68 | * Closes this session and the underlying web socket connection. 69 | * This effectively **closes all child sessions**, because they're based on the same web socket connection. 70 | */ 71 | suspend fun close() 72 | } 73 | 74 | /** 75 | * Performs the given operation in this session and closes the web socket connection. 76 | * 77 | * Note: This effectively closes all child sessions, because they're based on the same web socket connection. 78 | */ 79 | suspend inline fun BrowserSession.use(block: (BrowserSession) -> T): T = use(block) { close() } 80 | 81 | /** 82 | * Info about a session and its underlying target. 83 | */ 84 | interface SessionMetaData { 85 | 86 | /** 87 | * The ID of this session. 88 | */ 89 | val sessionId: SessionID 90 | 91 | /** 92 | * The ID of the attached target. 93 | */ 94 | val targetId: TargetID 95 | 96 | /** 97 | * The type of the attached target. 98 | */ 99 | val targetType: String 100 | } 101 | 102 | /** 103 | * A target session that is a child of a browser session, usually created when attaching to a target from the root 104 | * browser session. 105 | */ 106 | interface ChildSession : ChromeSession { 107 | 108 | /** 109 | * The parent browser session that created this target session. 110 | * 111 | * This is described in the 112 | * [session hierarchy section](https://github.com/aslushnikov/getting-started-with-cdp/blob/master/README.md#session-hierarchy) 113 | * in the "getting started" guide. 114 | */ 115 | val parent: BrowserSession 116 | 117 | /** 118 | * Info about this session and its underlying target. 119 | */ 120 | val metaData: SessionMetaData 121 | 122 | /** 123 | * Detaches this session from its target, effectively closing this session and all its child sessions, but without 124 | * closing the corresponding target (this leaves the tab open in the case of a page session). 125 | * 126 | * This preserves the underlying web socket connection (of the parent browser session), because it could be used 127 | * by other page sessions. 128 | */ 129 | suspend fun detach() 130 | 131 | /** 132 | * Closes this session and its target. If the target is a page, the tab gets closed. 133 | * 134 | * This only closes the corresponding tab, but preserves the underlying web socket connection (of the parent 135 | * browser session), because it could be used by other page sessions. 136 | * 137 | * If [keepBrowserContext] is true, the browser context of this page session will be preserved, which means 138 | * that other tabs that were opened from this page session will not be force-closed. 139 | */ 140 | suspend fun close(keepBrowserContext: Boolean = false) 141 | } 142 | 143 | /** 144 | * Performs the given operation in this [ChildSession] and closes this session and its children, as well as the 145 | * corresponding targets. 146 | * 147 | * This preserves the underlying web socket connection (of the parent [BrowserSession]), because it could be used by 148 | * other sessions that are children of the same browser session, but not children of this [ChildSession]. 149 | * 150 | * If you don't want to close child targets created during this session, use [ChildSession.close] with 151 | * `keepBrowserContext=true` instead of this helper. 152 | */ 153 | suspend inline fun S.use(block: (S) -> T): T = use(block) { close() } 154 | 155 | @PublishedApi 156 | internal inline fun R.use(block: (R) -> T, close: R.() -> Unit): T { 157 | var userFailure: Throwable? = null 158 | try { 159 | return block(this) 160 | } catch (t: Throwable) { 161 | userFailure = t 162 | throw t 163 | } finally { 164 | try { 165 | close() 166 | } catch (t: Throwable) { 167 | // Errors in close() shouldn't hide errors from the user's block, so we throw the user error instead, 168 | // and add the close error as suppressed. 169 | if (userFailure != null) { 170 | userFailure.addSuppressed(t) 171 | throw userFailure 172 | } 173 | throw t 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /protocol-generator/cdp-kotlin-generator/src/main/kotlin/org/hildan/chrome/devtools/protocol/generator/DomainTypesGenerator.kt: -------------------------------------------------------------------------------- 1 | package org.hildan.chrome.devtools.protocol.generator 2 | 3 | import com.squareup.kotlinpoet.* 4 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy 5 | import org.hildan.chrome.devtools.protocol.model.ChromeDPDomain 6 | import org.hildan.chrome.devtools.protocol.model.ChromeDPType 7 | import org.hildan.chrome.devtools.protocol.model.DomainTypeDeclaration 8 | import org.hildan.chrome.devtools.protocol.names.Annotations 9 | import org.hildan.chrome.devtools.protocol.names.ExtDeclarations 10 | import org.hildan.chrome.devtools.protocol.names.UndefinedEnumEntryName 11 | 12 | fun ChromeDPDomain.createDomainTypesFileSpec(): FileSpec = 13 | FileSpec.builder(packageName = names.packageName, fileName = names.typesFilename).apply { 14 | addAnnotation(Annotations.suppressWarnings) 15 | types.forEach { addDomainType(it, experimentalDomain = experimental) } 16 | }.build() 17 | 18 | private fun FileSpec.Builder.addDomainType(typeDeclaration: DomainTypeDeclaration, experimentalDomain: Boolean) { 19 | when (val type = typeDeclaration.type) { 20 | is ChromeDPType.Object -> addType(typeDeclaration.toDataClassTypeSpec(type)) 21 | is ChromeDPType.Enum -> addTypes(typeDeclaration.toEnumAndSerializerTypeSpecs(type, experimentalDomain)) 22 | is ChromeDPType.NamedRef -> addTypeAlias(typeDeclaration.toTypeAliasSpec(type)) 23 | } 24 | } 25 | 26 | private fun DomainTypeDeclaration.toDataClassTypeSpec(type: ChromeDPType.Object): TypeSpec = 27 | TypeSpec.classBuilder(names.className).apply { 28 | addModifiers(KModifier.DATA) 29 | addKDocAndStabilityAnnotations(element = this@toDataClassTypeSpec) 30 | addAnnotation(Annotations.serializable) 31 | addPrimaryConstructorProps(type.properties) 32 | }.build() 33 | 34 | private fun DomainTypeDeclaration.toEnumAndSerializerTypeSpecs(type: ChromeDPType.Enum, experimentalDomain: Boolean): List = 35 | if (experimental || experimentalDomain || type.isNonExhaustive) { 36 | val serializerTypeSpec = serializerForFCEnum(names.className, type.enumValues) 37 | val serializerClass = ClassName(names.packageName, serializerTypeSpec.name!!) 38 | listOf(serializerTypeSpec, toFCEnumTypeSpec(type, serializerClass)) 39 | } else { 40 | listOf(toStableEnumTypeSpec(type)) 41 | } 42 | 43 | private fun DomainTypeDeclaration.toStableEnumTypeSpec(type: ChromeDPType.Enum): TypeSpec = 44 | TypeSpec.enumBuilder(names.className).apply { 45 | addKDocAndStabilityAnnotations(element = this@toStableEnumTypeSpec) 46 | type.enumValues.forEach { 47 | addEnumConstant( 48 | name = protocolEnumEntryNameToKotlinName(it), 49 | typeSpec = TypeSpec.anonymousClassBuilder().addAnnotation(Annotations.serialName(it)).build() 50 | ) 51 | } 52 | addAnnotation(Annotations.serializable) 53 | }.build() 54 | 55 | private fun DomainTypeDeclaration.toFCEnumTypeSpec(type: ChromeDPType.Enum, serializerClass: ClassName): TypeSpec = 56 | TypeSpec.interfaceBuilder(names.className).apply { 57 | addModifiers(KModifier.SEALED) 58 | addKDocAndStabilityAnnotations(element = this@toFCEnumTypeSpec) 59 | addAnnotation(Annotations.serializableWith(serializerClass)) 60 | 61 | type.enumValues.forEach { 62 | val kotlinName = protocolEnumEntryNameToKotlinName(it) 63 | addType(TypeSpec.objectBuilder(kotlinName).apply { 64 | addModifiers(KModifier.DATA) 65 | addSuperinterface(names.className) 66 | 67 | // Reserved in JS 68 | if (kotlinName == "length") { 69 | addAnnotation(Annotations.jsName("length_kt")) 70 | } 71 | 72 | // For calls to serializers made directly with this sub-object instead of the FC enum's interface. 73 | // Example: Json.encodeToString(AXPropertyName.url) 74 | // (and not Json.encodeToString(AXPropertyName.url)) 75 | addAnnotation(Annotations.serializableWith(serializerClass)) 76 | }.build()) 77 | } 78 | require(type.enumValues.none { it.equals(UndefinedEnumEntryName, ignoreCase = true) }) { 79 | "Cannot synthesize the '$UndefinedEnumEntryName' value for experimental enum " + 80 | "${names.declaredName} (of domain ${names.domain.domainName}) because it clashes with an " + 81 | "existing value (case-insensitive). Values:\n - ${type.enumValues.joinToString("\n - ")}" 82 | } 83 | addType(notInProtocolClassTypeSpec(serializerClass)) 84 | }.build() 85 | 86 | private fun DomainTypeDeclaration.notInProtocolClassTypeSpec(serializerClass: ClassName) = 87 | TypeSpec.classBuilder(UndefinedEnumEntryName).apply { 88 | addModifiers(KModifier.DATA) 89 | addSuperinterface(names.className) 90 | 91 | // For calls to serializers made directly with this sub-object instead of the FC enum's interface. 92 | // Example: Json.encodeToString(AXPropertyName.NotDefinedInProtocol("notRendered")) 93 | // (and not Json.encodeToString(AXPropertyName.NotDefinedInProtocol("notRendered")) 94 | addAnnotation(Annotations.serializableWith(serializerClass)) 95 | 96 | addKdoc( 97 | "This extra enum entry represents values returned by Chrome that were not defined in " + 98 | "the protocol (for instance new values that were added later)." 99 | ) 100 | primaryConstructor(FunSpec.constructorBuilder().apply { 101 | addParameter("value", String::class) 102 | }.build()) 103 | addProperty(PropertySpec.builder("value", String::class).initializer("value").build()) 104 | }.build() 105 | 106 | private fun serializerForFCEnum(fcEnumClass: ClassName, enumValues: List): TypeSpec = 107 | TypeSpec.objectBuilder("${fcEnumClass.simpleName}Serializer").apply { 108 | addModifiers(KModifier.PRIVATE) 109 | superclass(ExtDeclarations.fcEnumSerializer.parameterizedBy(fcEnumClass)) 110 | addSuperclassConstructorParameter("%T::class", fcEnumClass) 111 | 112 | addFunction(FunSpec.builder("fromCode").apply { 113 | addModifiers(KModifier.OVERRIDE) 114 | addParameter("code", String::class) 115 | returns(fcEnumClass) 116 | beginControlFlow("return when (code)") 117 | enumValues.forEach { 118 | addCode("%S -> %T\n", it, fcEnumClass.nestedClass(protocolEnumEntryNameToKotlinName(it))) 119 | } 120 | addCode("else -> %T(code)", fcEnumClass.nestedClass(UndefinedEnumEntryName)) 121 | endControlFlow() 122 | }.build()) 123 | 124 | addFunction(FunSpec.builder("codeOf").apply { 125 | addModifiers(KModifier.OVERRIDE) 126 | addParameter("value", fcEnumClass) 127 | returns(String::class) 128 | beginControlFlow("return when (value)") 129 | enumValues.forEach { 130 | addCode("is %T -> %S\n", fcEnumClass.nestedClass(protocolEnumEntryNameToKotlinName(it)), it) 131 | } 132 | addCode("is %T -> value.value", fcEnumClass.nestedClass(UndefinedEnumEntryName)) 133 | endControlFlow() 134 | }.build()) 135 | }.build() 136 | 137 | private fun protocolEnumEntryNameToKotlinName(protocolName: String) = protocolName.dashesToCamelCase() 138 | 139 | private fun String.dashesToCamelCase(): String = replace(Regex("""-(\w)""")) { it.groupValues[1].uppercase() } 140 | 141 | private fun DomainTypeDeclaration.toTypeAliasSpec(type: ChromeDPType.NamedRef): TypeAliasSpec = 142 | TypeAliasSpec.builder(names.declaredName, type.typeName).apply { 143 | addKDocAndStabilityAnnotations(element = this@toTypeAliasSpec) 144 | }.build() 145 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/LocalIntegrationTestBase.kt: -------------------------------------------------------------------------------- 1 | import kotlinx.coroutines.* 2 | import kotlinx.coroutines.flow.first 3 | import org.hildan.chrome.devtools.* 4 | import org.hildan.chrome.devtools.domains.dom.* 5 | import org.hildan.chrome.devtools.domains.runtime.RemoteObjectSubtype 6 | import org.hildan.chrome.devtools.protocol.* 7 | import org.hildan.chrome.devtools.sessions.* 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.extension.RegisterExtension 11 | import org.testcontainers.* 12 | import kotlin.test.* 13 | import kotlin.time.Duration.Companion.seconds 14 | 15 | abstract class LocalIntegrationTestBase : IntegrationTestBase() { 16 | 17 | companion object { 18 | @RegisterExtension 19 | val resourceServer = TestResourcesServerExtension() 20 | } 21 | 22 | @BeforeEach 23 | fun register() { 24 | Testcontainers.exposeHostPorts(resourceServer.port) 25 | } 26 | 27 | protected suspend fun PageSession.gotoTestPageResource(resourcePath: String) { 28 | goto("http://host.testcontainers.internal:${resourceServer.port}/$resourcePath") 29 | } 30 | 31 | @OptIn(ExperimentalChromeApi::class) 32 | @Test 33 | fun basicFlow_fileScheme() = runTestWithRealTime { 34 | chromeWebSocket().use { browser -> 35 | val pageSession = browser.newPage() 36 | val targetId = pageSession.metaData.targetId 37 | 38 | pageSession.use { page -> 39 | page.gotoTestPageResource("basic.html") 40 | assertEquals("Basic tab title", page.target.getTargetInfo().targetInfo.title) 41 | assertTrue(browser.hasTarget(targetId), "the new target should be listed") 42 | } 43 | assertFalse(browser.hasTarget(targetId), "the new target should be closed (not listed)") 44 | } 45 | } 46 | 47 | @OptIn(ExperimentalChromeApi::class) 48 | @Test 49 | fun basicFlow_httpScheme() = runTestWithRealTime { 50 | chromeWebSocket().use { browser -> 51 | val pageSession = browser.newPage() 52 | val targetId = pageSession.metaData.targetId 53 | 54 | pageSession.use { page -> 55 | page.gotoTestPageResource("basic.html") 56 | assertEquals("Basic tab title", page.target.getTargetInfo().targetInfo.title) 57 | assertTrue(browser.hasTarget(targetId), "the new target should be listed") 58 | } 59 | assertFalse(browser.hasTarget(targetId), "the new target should be closed (not listed)") 60 | } 61 | } 62 | 63 | @Test 64 | fun page_getTargets_fileScheme() = runTestWithRealTime { 65 | chromeWebSocket().use { browser -> 66 | browser.newPage().use { page -> 67 | page.gotoTestPageResource("basic.html") 68 | val targets = page.target.getTargets().targetInfos 69 | val targetInfo = targets.first { it.targetId == page.metaData.targetId } 70 | assertEquals("page", targetInfo.type) 71 | assertTrue(targetInfo.attached) 72 | assertTrue(targetInfo.url.contains("basic.html")) 73 | } 74 | } 75 | } 76 | 77 | @Test 78 | fun page_getTargets_httpScheme() = runTestWithRealTime { 79 | chromeWebSocket().use { browser -> 80 | browser.newPage().use { page -> 81 | page.gotoTestPageResource("basic.html") 82 | val targets = page.target.getTargets().targetInfos 83 | val targetInfo = targets.first { it.targetId == page.metaData.targetId } 84 | assertEquals("page", targetInfo.type) 85 | assertTrue(targetInfo.attached) 86 | assertTrue(targetInfo.url.contains("basic.html")) 87 | } 88 | } 89 | } 90 | 91 | @OptIn(ExperimentalChromeApi::class) 92 | @Test 93 | fun page_goto() = runTestWithRealTime { 94 | chromeWebSocket().use { browser -> 95 | browser.newPage().use { page -> 96 | page.gotoTestPageResource("basic.html") 97 | assertEquals("Basic tab title", page.target.getTargetInfo().targetInfo.title) 98 | 99 | page.gotoTestPageResource("other.html") 100 | assertEquals("Other tab title", page.target.getTargetInfo().targetInfo.title) 101 | val nodeId = withTimeoutOrNull(5.seconds) { 102 | page.dom.awaitNodeBySelector("p[class='some-p-class']") 103 | } 104 | assertNotNull( 105 | nodeId, 106 | "timed out while waiting for DOM node with attribute: p[class='some-p-class']" 107 | ) 108 | 109 | val getOuterHTMLResponse = page.dom.getOuterHTML(GetOuterHTMLRequest(nodeId = nodeId)) 110 | assertTrue(getOuterHTMLResponse.outerHTML.contains("

")) 111 | } 112 | } 113 | } 114 | 115 | @OptIn(ExperimentalChromeApi::class) 116 | @Test 117 | fun sessionThrowsIOExceptionIfAlreadyClosed() = runTestWithRealTime { 118 | val browser = chromeWebSocket() 119 | val session = browser.newPage() 120 | session.gotoTestPageResource("basic.html") 121 | 122 | browser.close() 123 | 124 | assertFailsWith { 125 | session.target.getTargetInfo().targetInfo 126 | } 127 | } 128 | 129 | @Test 130 | fun attributesAccess() = runTestWithRealTime { 131 | chromeWebSocket().use { browser -> 132 | browser.newPage().use { page -> 133 | page.gotoTestPageResource("select.html") 134 | 135 | val nodeId = page.dom.findNodeBySelector("select[name=pets] option[selected]") 136 | assertNull(nodeId, "No option is selected in this ") 140 | 141 | val attributes2 = page.dom.getTypedAttributes("select[name=pets-selected] option[selected]") 142 | assertNotNull(attributes2, "There should be a selected option") 143 | assertEquals(true, attributes2.selected) 144 | assertEquals("cat", attributes2.value) 145 | val value = page.dom.getAttributeValue("select[name=pets-selected] option[selected]", "value") 146 | assertEquals("cat", value) 147 | // Attributes without value (e.g. "selected" in