diff --git a/.github/scripts/resolve-appium-versions.mjs b/.github/scripts/resolve-appium-versions.mjs new file mode 100644 index 000000000..0e45e59dc --- /dev/null +++ b/.github/scripts/resolve-appium-versions.mjs @@ -0,0 +1,63 @@ +// Resolves the versions of the Appium server and of a driver to install in e2e jobs. +// Usage: node resolve-appium-versions.mjs [--stable] +// Both get their beta versions if both have a beta that is newer than the latest stable release, +// otherwise both get the latest stable ones. With --stable the stable versions are always used. +// The result is written to the step outputs `server` and `driver`. +import {execFile} from 'node:child_process'; +import {appendFile} from 'node:fs/promises'; +import {promisify} from 'node:util'; + +const execFileAsync = promisify(execFile); + +async function distTags(pkg) { + const {stdout} = await execFileAsync('npm', ['view', pkg, 'dist-tags', '--json']); + return JSON.parse(stdout); +} + +// A prerelease of a version is older than that version, so only a higher major.minor.patch is newer +function isBetaNewerThanStable(beta, stable) { + const core = (version) => version.split('-')[0].split('.').map(Number); + const [b, s] = [core(beta), core(stable)]; + for (let i = 0; i < 3; i++) { + if (b[i] !== s[i]) { + return b[i] > s[i]; + } + } + return false; +} + +async function main(args) { + const stableOnly = args.includes('--stable'); + const [serverPackage, driverPackage] = args.filter((arg) => arg !== '--stable'); + if (!serverPackage || !driverPackage) { + throw new Error('Usage: resolve-appium-versions.mjs [--stable] '); + } + + const packages = await Promise.all( + [serverPackage, driverPackage].map(async (name) => { + const {latest, beta} = await distTags(name); + return {name, latest, beta, betaIsNewer: Boolean(beta) && isBetaNewerThanStable(beta, latest)}; + }), + ); + const useBeta = !stableOnly && packages.every((p) => p.betaIsNewer); + const [server, driver] = packages.map((p) => (useBeta ? p.beta : p.latest)); + + const summary = [ + `Appium versions to install: ${useBeta ? 'beta' : 'stable'}${stableOnly ? ' (forced)' : ''}`, + ...packages.map((p) => `- ${p.name}: stable ${p.latest}, beta ${p.beta ?? 'none'} => ${useBeta ? p.beta : p.latest}`), + ].join('\n'); + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } + if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, `server=${server}\ndriver=${driver}\n`); + } +} + +try { + await main(process.argv.slice(2)); +} catch (e) { + console.error(e.message); + process.exit(1); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 852ff163e..2236993b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,12 +127,22 @@ jobs: with: node-version: 'lts/*' + # The beta versions of the server and of the driver are used if both are newer than the stable ones, + # except for the Flutter tests, which always use the stable ones + - name: Resolve Appium versions + id: appium-versions + run: >- + node .github/scripts/resolve-appium-versions.mjs + ${{ startsWith(matrix.e2e-tests, 'flutter') && '--stable' || '' }} + appium + ${{ (matrix.e2e-tests == 'android' || matrix.e2e-tests == 'flutter-android') && 'appium-uiautomator2-driver' || 'appium-xcuitest-driver' }} + - name: Install Appium - run: npm install --location=global appium + run: npm install --location=global appium@${{ steps.appium-versions.outputs.server }} - name: Install UIA2 driver if: matrix.e2e-tests == 'android' || matrix.e2e-tests == 'flutter-android' - run: appium driver install uiautomator2 + run: appium driver install uiautomator2@${{ steps.appium-versions.outputs.driver }} - name: Install Flutter Integration driver if: matrix.e2e-tests == 'flutter-android' || matrix.e2e-tests == 'flutter-ios' @@ -168,16 +178,17 @@ jobs: - name: Prepare iOS simulator if: matrix.e2e-tests == 'ios' || matrix.e2e-tests == 'flutter-ios' - uses: futureware-tech/simulator-action@v5 + uses: futureware-tech/simulator-action@v6 with: model: "${{ env.IOS_DEVICE_NAME }}" os_version: "${{ env.IOS_PLATFORM_VERSION }}" wait_for_boot: true + settle_timeout_seconds: 180 shutdown_after_job: false - name: Install XCUITest driver if: matrix.e2e-tests == 'ios' || matrix.e2e-tests == 'flutter-ios' - run: appium driver install xcuitest + run: appium driver install xcuitest@${{ steps.appium-versions.outputs.driver }} - name: Download prebuilt WDA if: matrix.e2e-tests == 'ios' || matrix.e2e-tests == 'flutter-ios' diff --git a/README.md b/README.md index 61f02373c..43de7c290 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,9 @@ possible platforms, e.g. mobile and desktop operating systems, IOT devices, etc. talks to the server through its own HTTP client, and it is not so strictly focused on web-browser related operations. +Code that needs the Selenium `RemoteWebDriver`, for example the Selenium `Augmenter`, or Selenium BiDi modules, +can use the optional `io.appium:java-client-selenium-bridge` artifact. See [Selenium interoperability](docs/selenium-bridge.md). + ## Appium Server Service Wrapper Appium java client provides a dedicated class to control Appium server execution. diff --git a/build.gradle b/build.gradle index a0768a48f..555984ef8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ import org.apache.tools.ant.filters.* +import org.gradle.api.publish.maven.MavenPom plugins { id 'java-library' @@ -21,20 +22,22 @@ ext { group = 'io.appium' version = appiumClientVersion -repositories { - mavenCentral() +allprojects { + repositories { + mavenCentral() - // Only add Selenium snapshot repo when resolving a SNAPSHOT version. - // Release versions (e.g. from gradle.properties) resolve from Maven Central only. - if (project.property('selenium.version').toString().contains('SNAPSHOT')) { - maven { - name = 'Central Portal Snapshots' - url = 'https://central.sonatype.com/repository/maven-snapshots/' - mavenContent { - snapshotsOnly() - } - content { - includeGroup("org.seleniumhq.selenium") + // Only add Selenium snapshot repo when resolving a SNAPSHOT version. + // Release versions (e.g. from gradle.properties) resolve from Maven Central only. + if (project.property('selenium.version').toString().contains('SNAPSHOT')) { + maven { + name = 'Central Portal Snapshots' + url = 'https://central.sonatype.com/repository/maven-snapshots/' + mavenContent { + snapshotsOnly() + } + content { + includeGroup("org.seleniumhq.selenium") + } } } } @@ -111,6 +114,53 @@ javadoc { options.addStringOption('encoding', 'UTF-8') } +ext.configureCommonPom = { MavenPom pom -> + pom.url = 'http://appium.io' + pom.developers { + developer { + name = 'Jonah Stiennon' + email = 'jonahss@gmail.com' + url = 'https://github.com/jonahss' + id = 'jonahss' + } + developer { + name = 'Sergey Tikhomirov' + email = 'tichomirovsergey@gmail.com' + url = 'https://github.com/TikhomirovSergey' + id = 'TikhomirovSergey' + } + developer { + name = 'Srinivasan Sekar' + email = 'srinivasan.sekar1990@gmail.com' + url = 'https://github.com/SrinivasanTarget' + id = 'SrinivasanTarget' + } + developer { + name = 'Mykola Mokhnach' + url = 'https://github.com/mykola-mokhnach' + id = 'mykola-mokhnach' + } + developer { + name = 'Valery Yatsynovich' + url = 'https://github.com/valfirst' + id = 'valfirst' + } + } + pom.licenses { + license { + name = 'Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution = 'repo' + } + } + pom.scm { + url = 'https://github.com/appium/java-client' + connection = 'scm:git:ssh://git@github.com/appium/java-client.git' + developerConnection = 'scm:git:ssh://git@github.com/appium/java-client.git' + tag = 'HEAD' + } +} + publishing { publications { mavenJava(MavenPublication) { @@ -121,50 +171,7 @@ publishing { pom { name = 'java-client' description = 'Java client for Appium Mobile Webdriver' - url = 'http://appium.io' - developers { - developer { - name = 'Jonah Stiennon' - email = 'jonahss@gmail.com' - url = 'https://github.com/jonahss' - id = 'jonahss' - } - developer { - name = 'Sergey Tikhomirov' - email = 'tichomirovsergey@gmail.com' - url = 'https://github.com/TikhomirovSergey' - id = 'TikhomirovSergey' - } - developer { - name = 'Srinivasan Sekar' - email = 'srinivasan.sekar1990@gmail.com' - url = 'https://github.com/SrinivasanTarget' - id = 'SrinivasanTarget' - } - developer { - name = 'Mykola Mokhnach' - url = 'https://github.com/mykola-mokhnach' - id = 'mykola-mokhnach' - } - developer { - name = 'Valery Yatsynovich' - url = 'https://github.com/valfirst' - id = 'valfirst' - } - } - licenses { - license { - name = 'Apache License, Version 2.0' - url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' - distribution = 'repo' - } - } - scm { - url = 'https://github.com/appium/java-client' - connection = 'scm:git:ssh://git@github.com/appium/java-client.git' - developerConnection = 'scm:git:ssh://git@github.com/appium/java-client.git' - tag = 'HEAD' - } + configureCommonPom(it) } } } @@ -187,6 +194,7 @@ jreleaser { active = 'ALWAYS' url = 'https://central.sonatype.com/api/v1/publisher' stagingRepository('build/staging-deploy') + stagingRepository('selenium-bridge/build/staging-deploy') } } } diff --git a/docs/selenium-bridge.md b/docs/selenium-bridge.md new file mode 100644 index 000000000..a820653ce --- /dev/null +++ b/docs/selenium-bridge.md @@ -0,0 +1,56 @@ +# Selenium interoperability + +Appium Java client depends on `selenium-api` only, and its drivers are not Selenium `RemoteWebDriver` instances. +The optional `java-client-selenium-bridge` artifact adapts an Appium driver for the Selenium code that needs one, +for example the Selenium `Augmenter`, or the BiDi modules like `LogInspector`. It depends on +`selenium-remote-driver`, so it is compiled against the Selenium releases from 4.50.0 on, like the core artifact. + +```gradle +dependencies { + implementation('io.appium:java-client:X.Y.Z') + implementation('io.appium:java-client-selenium-bridge:X.Y.Z') +} +``` + +## RemoteWebDriver + +`SeleniumBridge.asRemoteWebDriver` returns a Selenium `RemoteWebDriver` that works in the session of the Appium +driver. Both share the same session and the same HTTP client, so commands can be mixed freely. As long as the +result is referenced, the same instance is returned for the same Appium driver. Quitting the Selenium driver +quits the session. + +```java +var driver = new AndroidDriver(serverUrl, options); +RemoteWebDriver selenium = SeleniumBridge.asRemoteWebDriver(driver); +WebDriver augmented = new Augmenter().augment(selenium); +``` + +Elements are not interchangeable between the two drivers: an element that one driver found cannot be passed to +the other, for example as a script argument. Find the element again with the driver that needs it. + +The commands of Selenium that Appium does not serve, like downloads, are not available. + +## BiDi + +Create the session with the `webSocketUrl` capability (`options.enableBiDi()`), then pass the Selenium driver +to a BiDi module. Selenium opens the WebSocket connection when the bridge is created, with the timeouts, proxy, +credentials and SSL context of the `AppiumClientConfig` of the driver. If the connection cannot be made, the +BiDi modules fail with a `BiDiException`. + +```java +var driver = new AndroidDriver(serverUrl, options.enableBiDi()); +var selenium = SeleniumBridge.asRemoteWebDriver(driver); +try (var logInspector = new LogInspector(selenium)) { + logInspector.onGenericLog(entry -> System.out.println(entry.getText())); + driver.getPageSource(); +} finally { + selenium.closeBiDi(); +} +``` + +The connection stays open until `closeBiDi()` or `quit()`, so release it when you are done with the BiDi modules. +`closeBiDi()` keeps the session open, but the modules that were created before cannot be used afterwards. +The next `asRemoteWebDriver` call opens a new connection. + +To customize the HTTP client that opens the WebSocket connection, use the `asRemoteWebDriver` overload that takes +a Selenium `HttpClient.Factory`. It creates a new Selenium driver with its own connection on every call. diff --git a/docs/v10-to-v11-migration-guide.md b/docs/v10-to-v11-migration-guide.md index e6ddfad6f..f4886e1be 100644 --- a/docs/v10-to-v11-migration-guide.md +++ b/docs/v10-to-v11-migration-guide.md @@ -23,7 +23,9 @@ in your own build. are `io.appium.java_client.remote.AppiumWebElement` instead of `org.openqa.selenium.remote.RemoteWebElement`. Both still implement `WebDriver` and `WebElement` from `selenium-api`, so code that is typed against the interfaces does not need any change. Casts to `RemoteWebDriver` and `RemoteWebElement`, and code that -requires them (for example the Selenium `Augmenter`), must be replaced. +requires them (for example the Selenium `Augmenter`), must be replaced. The optional +`io.appium:java-client-selenium-bridge` artifact adapts an Appium driver to a `RemoteWebDriver` that works in the +same session, see [Selenium interoperability](selenium-bridge.md). - The following types moved to the `io.appium.java_client.remote` package: `Response`, `Command`, `CommandPayload`, `SessionId`, `DriverCommand`, `ExecuteMethod`, `CommandExecutor`, `ErrorHandler`, `ErrorCodes`, `ScreenshotException` and `UnreachableBrowserException`. @@ -77,3 +79,6 @@ returns a `Map`, because Guava is not a dependency anymore. - `AppiumDriver` does not implement `HasBiDi` anymore and the `getBiDi` and `maybeGetBiDi` methods are removed. Selenium deprecated them for removal and changed the BiDi API in an incompatible way in the recent releases. The BiDi session address is still available in the `webSocketUrl` capability of the created session. +- To use the Selenium BiDi modules (for example `LogInspector`), add the `io.appium:java-client-selenium-bridge` +artifact and wrap the driver with `SeleniumBridge.asRemoteWebDriver(driver)`. See +[Selenium interoperability](selenium-bridge.md). diff --git a/selenium-bridge/build.gradle b/selenium-bridge/build.gradle new file mode 100644 index 000000000..7744b920f --- /dev/null +++ b/selenium-bridge/build.gradle @@ -0,0 +1,139 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'checkstyle' +} + +evaluationDependsOn(':') + +group = 'io.appium' +version = rootProject.version + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + withJavadocJar() + withSourcesJar() +} + +['jar', 'sourcesJar', 'javadocJar'].each { archiveTask -> + tasks.named(archiveTask, Jar) { + from(rootProject.files('LICENSE', 'NOTICE')) { + into 'META-INF' + } + } +} + +def seleniumVersion = rootProject.ext.seleniumVersion +def slf4jVersion = rootProject.ext.slf4jVersion + +dependencies { + api project(':') + // Same range as the core module, so that both resolve to a single Selenium version + if (seleniumVersion.toString().contains('SNAPSHOT')) { + api "org.seleniumhq.selenium:selenium-remote-driver:${seleniumVersion}" + } else { + api('org.seleniumhq.selenium:selenium-remote-driver') { + version { + strictly "[${seleniumVersion}, 5.0)" + prefer "${seleniumVersion}" + } + } + } + implementation "org.slf4j:slf4j-api:${slf4jVersion}" + implementation 'org.jspecify:jspecify:1.0.1' +} + +tasks.withType(JavaCompile).configureEach { + options.compilerArgs += ['-Xlint:deprecation', '-Xlint:removal'] +} + +checkstyle { + toolVersion = '10.23.1' + configFile = rootProject.file('config/checkstyle/appium-style.xml') + configDirectory = rootProject.file('config/checkstyle') + showViolations = true + ignoreFailures = false +} + +javadoc { + options.addStringOption('encoding', 'UTF-8') +} + +publishing { + publications { + mavenJava(MavenPublication) { + groupId = 'io.appium' + artifactId = 'java-client-selenium-bridge' + version = rootProject.version + from components.java + pom { + name = 'java-client-selenium-bridge' + description = 'Selenium interoperability (RemoteWebDriver adapter and BiDi) for Appium Java client' + rootProject.ext.configureCommonPom(it) + } + } + } + repositories { + maven { + url = layout.buildDirectory.dir('staging-deploy') + } + } +} + +testing { + suites { + configureEach { + useJUnitJupiter() + dependencies { + implementation 'org.junit.jupiter:junit-jupiter:5.14.4' + runtimeOnly 'org.junit.platform:junit-platform-launcher' + runtimeOnly "org.slf4j:slf4j-simple:${slf4jVersion}" + } + targets.configureEach { + testTask.configure { + testLogging { + showStandardStreams = true + exceptionFormat = 'full' + } + } + } + } + + e2eIosTest(JvmTestSuite) { + sources { + java { + srcDirs = ['src/e2eIosTest/java'] + } + } + dependencies { + implementation project() + implementation(rootProject.sourceSets.test.output) + implementation(rootProject.sourceSets.e2eIosTest.output) + } + targets.configureEach { + testTask.configure { + shouldRunAfter(test) + } + } + } + + e2eAndroidTest(JvmTestSuite) { + sources { + java { + srcDirs = ['src/e2eAndroidTest/java'] + } + } + dependencies { + implementation project() + implementation(rootProject.sourceSets.test.output) + implementation(rootProject.sourceSets.e2eAndroidTest.output) + } + targets.configureEach { + testTask.configure { + shouldRunAfter(test) + } + } + } + } +} diff --git a/selenium-bridge/src/e2eAndroidTest/java/io/appium/java_client/selenium/AndroidBiDiTest.java b/selenium-bridge/src/e2eAndroidTest/java/io/appium/java_client/selenium/AndroidBiDiTest.java new file mode 100644 index 000000000..5477ab692 --- /dev/null +++ b/selenium-bridge/src/e2eAndroidTest/java/io/appium/java_client/selenium/AndroidBiDiTest.java @@ -0,0 +1,56 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import io.appium.java_client.android.BaseAndroidTest; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.bidi.log.LogEntry; +import org.openqa.selenium.bidi.module.LogInspector; + +import java.util.concurrent.CopyOnWriteArrayList; + +import static io.appium.java_client.HasBrowserCheck.NATIVE_CONTEXT; +import static org.junit.jupiter.api.Assertions.assertFalse; + +public class AndroidBiDiTest extends BaseAndroidTest { + + @Test + public void listenForAndroidLogsOfAllContexts() { + var selenium = SeleniumBridge.asRemoteWebDriver(driver); + var logs = new CopyOnWriteArrayList(); + try (var logInspector = new LogInspector(selenium)) { + logInspector.onLog(logs::add); + driver.getPageSource(); + } finally { + selenium.closeBiDi(); + } + assertFalse(logs.isEmpty()); + } + + @Test + public void listenForAndroidLogsOfTheNativeContext() { + var selenium = SeleniumBridge.asRemoteWebDriver(driver); + var logs = new CopyOnWriteArrayList(); + try (var logInspector = new LogInspector(NATIVE_CONTEXT, selenium)) { + logInspector.onLog(logs::add); + driver.getPageSource(); + } finally { + selenium.closeBiDi(); + } + assertFalse(logs.isEmpty()); + } +} diff --git a/selenium-bridge/src/e2eIosTest/java/io/appium/java_client/selenium/IOSBiDiTest.java b/selenium-bridge/src/e2eIosTest/java/io/appium/java_client/selenium/IOSBiDiTest.java new file mode 100644 index 000000000..8ac9b555f --- /dev/null +++ b/selenium-bridge/src/e2eIosTest/java/io/appium/java_client/selenium/IOSBiDiTest.java @@ -0,0 +1,56 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import io.appium.java_client.ios.AppIOSTest; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.bidi.log.LogEntry; +import org.openqa.selenium.bidi.module.LogInspector; + +import java.util.concurrent.CopyOnWriteArrayList; + +import static io.appium.java_client.HasBrowserCheck.NATIVE_CONTEXT; +import static org.junit.jupiter.api.Assertions.assertFalse; + +public class IOSBiDiTest extends AppIOSTest { + + @Test + public void listenForIosLogsOfAllContexts() { + var selenium = SeleniumBridge.asRemoteWebDriver(driver); + var logs = new CopyOnWriteArrayList(); + try (var logInspector = new LogInspector(selenium)) { + logInspector.onLog(logs::add); + driver.getPageSource(); + } finally { + selenium.closeBiDi(); + } + assertFalse(logs.isEmpty()); + } + + @Test + public void listenForIosLogsOfTheNativeContext() { + var selenium = SeleniumBridge.asRemoteWebDriver(driver); + var logs = new CopyOnWriteArrayList(); + try (var logInspector = new LogInspector(NATIVE_CONTEXT, selenium)) { + logInspector.onLog(logs::add); + driver.getPageSource(); + } finally { + selenium.closeBiDi(); + } + assertFalse(logs.isEmpty()); + } +} diff --git a/selenium-bridge/src/main/java/io/appium/java_client/selenium/BridgedRemoteWebDriver.java b/selenium-bridge/src/main/java/io/appium/java_client/selenium/BridgedRemoteWebDriver.java new file mode 100644 index 000000000..79bba485c --- /dev/null +++ b/selenium-bridge/src/main/java/io/appium/java_client/selenium/BridgedRemoteWebDriver.java @@ -0,0 +1,112 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import io.appium.java_client.remote.AppiumCommandExecutor; +import io.appium.java_client.remote.AppiumRemoteWebDriver; +import org.openqa.selenium.ImmutableCapabilities; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WrapsDriver; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.remote.http.ClientConfig; +import org.openqa.selenium.remote.http.HttpClient; + +/** + * A Selenium {@link RemoteWebDriver} that works in the session of an Appium driver. Commands go through the + * command executor of the Appium driver. If the session was created with the {@code webSocketUrl} capability, + * the BiDi connection is made by Selenium itself when the bridge is created, so Selenium BiDi modules and the + * {@code Augmenter} can be used. + * + *

The BiDi connection stays open until {@link #closeBiDi()} or {@link #quit()}. Quitting the bridge quits + * the session of the Appium driver. Elements are not interchangeable between the drivers. + */ +public class BridgedRemoteWebDriver extends RemoteWebDriver implements WrapsDriver { + private AppiumRemoteWebDriver delegate; + private WebSocketClients webSocketClients; + private volatile boolean biDiClosed; + + /** + * Used by the Selenium {@code Augmenter}, which subclasses the driver and copies its fields. + */ + protected BridgedRemoteWebDriver() { + super(); + } + + BridgedRemoteWebDriver(AppiumRemoteWebDriver delegate, HttpClient.Factory webSocketClientFactory) { + this(delegate, new SeleniumCommandExecutor(delegate), new WebSocketClients(webSocketClientFactory)); + } + + private BridgedRemoteWebDriver(AppiumRemoteWebDriver delegate, SeleniumCommandExecutor executor, + WebSocketClients webSocketClients) { + super(executor, new ImmutableCapabilities(), webSocketClients.asFactory(), toSeleniumConfig(delegate)); + this.delegate = delegate; + this.webSocketClients = webSocketClients; + executor.enableQuit(); + } + + @Override + public WebDriver getWrappedDriver() { + return delegate; + } + + /** + * Closes the BiDi connection, while the session stays open. The BiDi modules that were created with + * this driver cannot be used afterwards. + */ + public void closeBiDi() { + biDiClosed = true; + webSocketClients.closeAll(); + } + + boolean isBiDiClosed() { + return biDiClosed; + } + + /** + * Quits the session of the Appium driver and closes the BiDi connection. + */ + @Override + public void quit() { + try { + super.quit(); + } finally { + closeBiDi(); + } + } + + private static ClientConfig toSeleniumConfig(AppiumRemoteWebDriver delegate) { + var config = ClientConfig.defaultConfig(); + if (!(delegate.getCommandExecutor() instanceof AppiumCommandExecutor)) { + return config; + } + var source = ((AppiumCommandExecutor) delegate.getCommandExecutor()).getAppiumClientConfig(); + config = config.baseUri(source.baseUri()) + .connectionTimeout(source.connectionTimeout()) + .readTimeout(source.readTimeout()) + .wsTimeout(source.wsTimeout()); + if (source.proxy() != null) { + config = config.proxy(source.proxy()); + } + if (source.credentials() != null) { + config = config.authenticateAs(source.credentials()); + } + if (source.sslContext() != null) { + config = config.sslContext(source.sslContext()); + } + return config; + } +} diff --git a/selenium-bridge/src/main/java/io/appium/java_client/selenium/SeleniumBridge.java b/selenium-bridge/src/main/java/io/appium/java_client/selenium/SeleniumBridge.java new file mode 100644 index 000000000..446e07e6d --- /dev/null +++ b/selenium-bridge/src/main/java/io/appium/java_client/selenium/SeleniumBridge.java @@ -0,0 +1,79 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import io.appium.java_client.remote.AppiumRemoteWebDriver; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.remote.http.HttpClient; + +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.WeakHashMap; + +import static java.util.Objects.requireNonNull; + +/** + * The entry point of the Selenium interoperability. Appium drivers are not Selenium {@link RemoteWebDriver} + * instances. This class adapts them for the code that requires one, for example the Selenium + * {@code Augmenter} or BiDi modules like {@code LogInspector}. + */ +public final class SeleniumBridge { + private static final Map> BRIDGES = + new WeakHashMap<>(); + + private SeleniumBridge() { + } + + /** + * Adapts an Appium driver to a {@link RemoteWebDriver} that works in the same session. The WebSocket + * connection of BiDi is made by the default Selenium HTTP client. As long as the result is referenced and + * its BiDi connection is not closed, the same instance is returned for the same driver, so repeated calls + * do not open more connections. + * + * @param driver the Appium driver, which must have a session + * @return the Selenium driver, which also implements {@code HasBiDi} + * @see BridgedRemoteWebDriver + */ + public static BridgedRemoteWebDriver asRemoteWebDriver(AppiumRemoteWebDriver driver) { + requireNonNull(driver, "Appium driver"); + synchronized (BRIDGES) { + var reference = BRIDGES.get(driver); + var existing = reference == null ? null : reference.get(); + if (existing != null && !existing.isBiDiClosed()) { + return existing; + } + var bridge = new BridgedRemoteWebDriver(driver, HttpClient.Factory.createDefault()); + BRIDGES.put(driver, new WeakReference<>(bridge)); + return bridge; + } + } + + /** + * Adapts an Appium driver to a {@link RemoteWebDriver} that works in the same session. Every call + * creates a new Selenium driver, with its own BiDi connection. + * + * @param driver the Appium driver, which must have a session + * @param webSocketClientFactory the factory of the HTTP clients that open the BiDi WebSocket connection + * @return the Selenium driver, which also implements {@code HasBiDi} + * @see BridgedRemoteWebDriver + */ + public static BridgedRemoteWebDriver asRemoteWebDriver(AppiumRemoteWebDriver driver, + HttpClient.Factory webSocketClientFactory) { + return new BridgedRemoteWebDriver(requireNonNull(driver, "Appium driver"), + requireNonNull(webSocketClientFactory, "WebSocket client factory")); + } +} diff --git a/selenium-bridge/src/main/java/io/appium/java_client/selenium/SeleniumCommandExecutor.java b/selenium-bridge/src/main/java/io/appium/java_client/selenium/SeleniumCommandExecutor.java new file mode 100644 index 000000000..579222fd5 --- /dev/null +++ b/selenium-bridge/src/main/java/io/appium/java_client/selenium/SeleniumCommandExecutor.java @@ -0,0 +1,89 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import io.appium.java_client.remote.AppiumRemoteWebDriver; +import org.jspecify.annotations.Nullable; +import org.openqa.selenium.Platform; +import org.openqa.selenium.remote.Command; +import org.openqa.selenium.remote.CommandExecutor; +import org.openqa.selenium.remote.DriverCommand; +import org.openqa.selenium.remote.Response; + +import java.io.IOException; +import java.util.LinkedHashMap; + +import static java.util.Objects.requireNonNull; + +/** + * Executes Selenium commands with the command executor of an Appium driver, so that both drivers share the + * same session and HTTP client. The new session command is answered with the existing session, which lets the + * Selenium driver take it over through its public constructor. + */ +final class SeleniumCommandExecutor implements CommandExecutor { + private final AppiumRemoteWebDriver delegate; + private volatile boolean quitEnabled; + + SeleniumCommandExecutor(AppiumRemoteWebDriver delegate) { + this.delegate = delegate; + } + + /** + * Lets the quit command end the session. Selenium quits the driver if its constructor fails, which must not + * end the session of the Appium driver, so the quit command is ignored until the bridge is created. + */ + void enableQuit() { + quitEnabled = true; + } + + @Override + @Nullable + public Response execute(Command command) throws IOException { + if (DriverCommand.NEW_SESSION.equals(command.getName())) { + return existingSession(); + } + if (DriverCommand.QUIT.equals(command.getName()) && !quitEnabled) { + var ignored = new Response(); + ignored.setState("success"); + return ignored; + } + var sessionId = command.getSessionId() == null + ? null : new io.appium.java_client.remote.SessionId(command.getSessionId().toString()); + var response = delegate.getCommandExecutor().execute( + new io.appium.java_client.remote.Command(sessionId, command.getName(), command.getParameters())); + if (response == null) { + return null; + } + var result = new Response(); + result.setSessionId(response.getSessionId()); + result.setState(response.getState()); + result.setValue(response.getValue()); + return result; + } + + private Response existingSession() { + var capabilities = new LinkedHashMap<>(delegate.getCapabilities().asMap()); + // The Selenium driver parses the platform name from a string + capabilities.computeIfPresent("platformName", + (key, value) -> value instanceof Platform ? ((Platform) value).name() : value); + var response = new Response(new org.openqa.selenium.remote.SessionId( + requireNonNull(delegate.getSessionId(), "The Appium driver does not have a session").toString())); + response.setState("success"); + response.setValue(capabilities); + return response; + } +} diff --git a/selenium-bridge/src/main/java/io/appium/java_client/selenium/WebSocketClients.java b/selenium-bridge/src/main/java/io/appium/java_client/selenium/WebSocketClients.java new file mode 100644 index 000000000..2f3e5ac9b --- /dev/null +++ b/selenium-bridge/src/main/java/io/appium/java_client/selenium/WebSocketClients.java @@ -0,0 +1,48 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import org.openqa.selenium.remote.http.HttpClient; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Creates the HTTP clients of the BiDi connections with a factory and remembers them, so that the connections + * can be closed without quitting the session. + */ +final class WebSocketClients { + private final HttpClient.Factory factory; + private final List clients = new CopyOnWriteArrayList<>(); + + WebSocketClients(HttpClient.Factory factory) { + this.factory = factory; + } + + HttpClient.Factory asFactory() { + return config -> { + var client = factory.createClient(config); + clients.add(client); + return client; + }; + } + + void closeAll() { + clients.forEach(HttpClient::close); + clients.clear(); + } +} diff --git a/selenium-bridge/src/test/java/io/appium/java_client/selenium/FakeBiDiServer.java b/selenium-bridge/src/test/java/io/appium/java_client/selenium/FakeBiDiServer.java new file mode 100644 index 000000000..7d496fc22 --- /dev/null +++ b/selenium-bridge/src/test/java/io/appium/java_client/selenium/FakeBiDiServer.java @@ -0,0 +1,206 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; + +/** + * A minimal WebSocket server that answers BiDi commands. It confirms session.status and session.subscribe and + * sends one log.entryAdded event after a subscription. + */ +final class FakeBiDiServer implements AutoCloseable { + private static final String GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + private static final Pattern ID = Pattern.compile("\"id\"\\s*:\\s*(\\d+)"); + private static final Pattern METHOD = Pattern.compile("\"method\"\\s*:\\s*\"([^\"]+)\""); + + private final ServerSocket serverSocket; + private final AtomicInteger connections = new AtomicInteger(); + private final AtomicInteger closedConnections = new AtomicInteger(); + private final CountDownLatch eventSent = new CountDownLatch(1); + + FakeBiDiServer() throws IOException { + serverSocket = new ServerSocket(0, 5, InetAddress.getLoopbackAddress()); + var thread = new Thread(this::acceptConnections, "fake-bidi-server"); + thread.setDaemon(true); + thread.start(); + } + + int port() { + return serverSocket.getLocalPort(); + } + + int connections() { + return connections.get(); + } + + boolean awaitClosedConnections(int expected, long timeout, TimeUnit unit) throws InterruptedException { + var deadline = System.nanoTime() + unit.toNanos(timeout); + while (closedConnections.get() < expected && System.nanoTime() < deadline) { + Thread.sleep(50); + } + return closedConnections.get() >= expected; + } + + boolean awaitEvent(long timeout, TimeUnit unit) throws InterruptedException { + return eventSent.await(timeout, unit); + } + + @Override + public void close() throws IOException { + serverSocket.close(); + } + + private void acceptConnections() { + while (!serverSocket.isClosed()) { + try { + var socket = serverSocket.accept(); + var thread = new Thread(() -> serve(socket), "fake-bidi-connection"); + thread.setDaemon(true); + thread.start(); + } catch (IOException e) { + return; + } + } + } + + private void serve(Socket socket) { + try (socket) { + var in = socket.getInputStream(); + var out = socket.getOutputStream(); + handshake(in, out); + connections.incrementAndGet(); + var data = new DataInputStream(in); + while (true) { + var opcode = data.readUnsignedByte() & 0x0f; + var payload = readPayload(data); + if (opcode == 0x8) { + writeFrame(out, 0x8, payload); + return; + } + if (opcode == 0x1) { + answer(out, new String(payload, StandardCharsets.UTF_8)); + } + } + } catch (IOException | NoSuchAlgorithmException e) { + // The client closed the connection + } finally { + closedConnections.incrementAndGet(); + } + } + + private static void handshake(InputStream in, OutputStream out) throws IOException, NoSuchAlgorithmException { + var buffer = new ByteArrayOutputStream(); + while (!buffer.toString(StandardCharsets.US_ASCII).endsWith("\r\n\r\n")) { + var b = in.read(); + if (b < 0) { + throw new IOException("Closed during the handshake"); + } + buffer.write(b); + } + var key = buffer.toString(StandardCharsets.US_ASCII).lines() + .filter(l -> l.toLowerCase().startsWith("sec-websocket-key:")) + .map(l -> l.substring(l.indexOf(':') + 1).trim()).findFirst().orElseThrow(); + var accept = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1") + .digest((key + GUID).getBytes(StandardCharsets.US_ASCII))); + out.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } + + private static byte[] readPayload(DataInputStream in) throws IOException { + var length = in.readUnsignedByte() & 0x7f; + if (length == 126) { + length = in.readUnsignedShort(); + } + var mask = new byte[4]; + in.readFully(mask); + var payload = new byte[length]; + in.readFully(payload); + for (int i = 0; i < length; i++) { + payload[i] ^= mask[i % 4]; + } + return payload; + } + + private void answer(OutputStream out, String message) throws IOException { + var id = ID.matcher(message); + var method = METHOD.matcher(message); + if (!id.find() || !method.find()) { + return; + } + String result; + switch (method.group(1)) { + case "session.status": + result = "{\"ready\":true,\"message\":\"ready\"}"; + break; + case "session.subscribe": + result = "{\"subscription\":\"sub1\"}"; + break; + default: + result = "{}"; + break; + } + writeText(out, "{\"type\":\"success\",\"id\":" + id.group(1) + ",\"result\":" + result + "}"); + if ("session.subscribe".equals(method.group(1))) { + // The client registers its listener after it has got the subscription id + sleepQuietly(300); + writeText(out, "{\"type\":\"event\",\"method\":\"log.entryAdded\",\"params\":{\"level\":\"info\"," + + "\"source\":{\"realm\":\"r1\"},\"text\":\"hello\",\"timestamp\":1,\"type\":\"generic\"}}"); + eventSent.countDown(); + } + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static synchronized void writeText(OutputStream out, String text) throws IOException { + writeFrame(out, 0x1, text.getBytes(StandardCharsets.UTF_8)); + } + + private static void writeFrame(OutputStream out, int opcode, byte[] payload) throws IOException { + out.write(0x80 | opcode); + if (payload.length < 126) { + out.write(payload.length); + } else { + out.write(126); + out.write(payload.length >> 8); + out.write(payload.length & 0xff); + } + out.write(payload); + out.flush(); + } +} diff --git a/selenium-bridge/src/test/java/io/appium/java_client/selenium/SeleniumBridgeTest.java b/selenium-bridge/src/test/java/io/appium/java_client/selenium/SeleniumBridgeTest.java new file mode 100644 index 000000000..20ac611a9 --- /dev/null +++ b/selenium-bridge/src/test/java/io/appium/java_client/selenium/SeleniumBridgeTest.java @@ -0,0 +1,264 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * See the NOTICE file distributed with this work for additional + * information regarding copyright ownership. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.java_client.selenium; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.appium.java_client.android.AndroidDriver; +import io.appium.java_client.android.options.UiAutomator2Options; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.bidi.BiDiException; +import org.openqa.selenium.bidi.HasBiDi; +import org.openqa.selenium.bidi.log.GenericLogEntry; +import org.openqa.selenium.bidi.module.LogInspector; +import org.openqa.selenium.devtools.HasDevTools; +import org.openqa.selenium.remote.Augmenter; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.remote.RemoteWebElement; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SeleniumBridgeTest { + private static final String ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"; + + private HttpServer server; + private FakeBiDiServer biDiServer; + private final List requests = new CopyOnWriteArrayList<>(); + private String webSocketUrl; + private String extraCapabilities = ""; + + @BeforeEach + void startServers() throws IOException { + biDiServer = new FakeBiDiServer(); + webSocketUrl = "ws://127.0.0.1:" + biDiServer.port() + "/session/s1"; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + @AfterEach + void stopServers() throws IOException { + server.stop(0); + biDiServer.close(); + } + + private void handle(HttpExchange exchange) throws IOException { + var key = exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath(); + var requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requests.add(key); + var status = 200; + String body; + switch (key) { + case "POST /session": + body = "{\"value\":{\"sessionId\":\"s1\",\"capabilities\":{\"platformName\":\"Android\"," + + (webSocketUrl == null ? "" : "\"webSocketUrl\":\"" + webSocketUrl + "\",") + + extraCapabilities + + "\"appium:automationName\":\"UiAutomator2\"}}}"; + break; + case "GET /session/s1/title": + body = "{\"value\":\"A title\"}"; + break; + case "POST /session/s1/element": + if (requestBody.contains("missing")) { + status = 404; + body = "{\"value\":{\"error\":\"no such element\",\"message\":\"not here\",\"stacktrace\":\"\"}}"; + } else { + body = "{\"value\":{\"" + ELEMENT_KEY + "\":\"el1\"}}"; + } + break; + case "GET /session/s1/element/el1/text": + body = "{\"value\":\"element text\"}"; + break; + default: + body = "{\"value\":null}"; + break; + } + var bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private AndroidDriver newAppiumDriver() throws IOException { + return new AndroidDriver(new URL("http://127.0.0.1:" + server.getAddress().getPort()), + new UiAutomator2Options().setDeviceName("Android Emulator")); + } + + @Test + void sharesTheSessionOfTheAppiumDriver() throws IOException { + var appiumDriver = newAppiumDriver(); + + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(appiumDriver); + + assertInstanceOf(RemoteWebDriver.class, seleniumDriver); + assertEquals("s1", String.valueOf(seleniumDriver.getSessionId())); + assertSame(appiumDriver, seleniumDriver.getWrappedDriver()); + assertEquals("UiAutomator2", seleniumDriver.getCapabilities().getCapability("appium:automationName")); + assertEquals(appiumDriver.getCapabilities().getPlatformName(), + seleniumDriver.getCapabilities().getPlatformName()); + requests.clear(); + assertEquals("A title", seleniumDriver.getTitle()); + var element = seleniumDriver.findElement(By.id("x")); + assertInstanceOf(RemoteWebElement.class, element); + assertEquals("element text", element.getText()); + assertEquals(List.of("GET /session/s1/title", "POST /session/s1/element", + "GET /session/s1/element/el1/text"), requests); + } + + @Test + void worksWithTheSeleniumAugmenter() throws IOException { + var augmented = new Augmenter().augment(SeleniumBridge.asRemoteWebDriver(newAppiumDriver())); + + assertEquals("A title", augmented.getTitle()); + } + + @Test + void mapsErrorsToSeleniumExceptions() throws IOException { + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(newAppiumDriver()); + + assertThrows(NoSuchElementException.class, () -> seleniumDriver.findElement(By.id("missing"))); + } + + @Test + void quitsTheSessionOfTheAppiumDriver() throws IOException { + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(newAppiumDriver()); + requests.clear(); + + seleniumDriver.quit(); + + assertEquals(List.of("DELETE /session/s1"), requests); + } + + @Test + void connectsBiDiFromTheWebSocketUrlCapability() throws IOException { + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(newAppiumDriver()); + + assertInstanceOf(HasBiDi.class, seleniumDriver); + assertNotNull(((HasBiDi) seleniumDriver).getHandle()); + assertEquals(1, biDiServer.connections()); + } + + @Test + void deliversBiDiEventsToSeleniumModules() throws Exception { + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(newAppiumDriver()); + var logs = new CopyOnWriteArrayList(); + + try (var logInspector = new LogInspector(seleniumDriver)) { + logInspector.onGenericLog(logs::add); + assertTrue(biDiServer.awaitEvent(5, TimeUnit.SECONDS)); + for (int i = 0; i < 50 && logs.isEmpty(); i++) { + Thread.sleep(100); + } + } + + assertEquals(1, logs.size()); + assertEquals("hello", logs.get(0).getText()); + seleniumDriver.quit(); + } + + @Test + void worksWithTheSeleniumAugmenterWhenItAugmentsTheDriver() throws IOException { + extraCapabilities = "\"se:cdp\":\"ws://127.0.0.1:1/devtools\","; + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(newAppiumDriver()); + + var augmented = new Augmenter().augment(seleniumDriver); + + assertInstanceOf(HasDevTools.class, augmented); + assertEquals("A title", augmented.getTitle()); + } + + @Test + void keepsTheSessionOfTheAppiumDriverIfTheBridgeCannotBeCreated() throws IOException { + var appiumDriver = newAppiumDriver(); + requests.clear(); + + assertThrows(IllegalStateException.class, () -> SeleniumBridge.asRemoteWebDriver(appiumDriver, config -> { + throw new IllegalStateException("bad factory"); + })); + + assertEquals("A title", appiumDriver.getTitle()); + assertFalse(requests.contains("DELETE /session/s1")); + } + + @Test + void opensOneBiDiConnectionForRepeatedCalls() throws IOException { + var appiumDriver = newAppiumDriver(); + + var first = SeleniumBridge.asRemoteWebDriver(appiumDriver); + var second = SeleniumBridge.asRemoteWebDriver(appiumDriver); + var third = SeleniumBridge.asRemoteWebDriver(appiumDriver); + + assertSame(first, second); + assertSame(first, third); + assertEquals(1, biDiServer.connections()); + } + + @Test + void closesTheBiDiConnectionWithoutQuittingTheSession() throws Exception { + var appiumDriver = newAppiumDriver(); + var first = SeleniumBridge.asRemoteWebDriver(appiumDriver); + requests.clear(); + + first.closeBiDi(); + + assertTrue(biDiServer.awaitClosedConnections(1, 5, TimeUnit.SECONDS)); + assertFalse(requests.contains("DELETE /session/s1")); + assertEquals("A title", first.getTitle()); + var second = SeleniumBridge.asRemoteWebDriver(appiumDriver); + assertNotSame(first, second); + assertEquals(2, biDiServer.connections()); + } + + @Test + void closesTheBiDiConnectionOnQuit() throws Exception { + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(newAppiumDriver()); + + seleniumDriver.quit(); + + assertTrue(biDiServer.awaitClosedConnections(1, 5, TimeUnit.SECONDS)); + } + + @Test + void failsToProvideBiDiWithoutTheWebSocketUrlCapability() throws IOException { + webSocketUrl = null; + var seleniumDriver = SeleniumBridge.asRemoteWebDriver(newAppiumDriver()); + + assertThrows(BiDiException.class, seleniumDriver::getHandle); + } +} diff --git a/settings.gradle b/settings.gradle index 729330437..040f9e1b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,3 @@ rootProject.name = 'java-client' +include 'selenium-bridge'