From 3a95df2d81866cc37411d0ce6ada517d0cb61139 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 20 Jan 2025 07:39:28 -0600 Subject: [PATCH] Adds shake command --- .../src/main/java/maestro/Driver.kt | 2 + .../src/main/java/maestro/Maestro.kt | 6 +++ .../java/maestro/drivers/AndroidDriver.kt | 5 +++ .../main/java/maestro/drivers/IOSDriver.kt | 6 +++ .../main/java/maestro/drivers/WebDriver.kt | 4 ++ .../kotlin/xcuitest/XCTestDriverClient.kt | 38 +++++++++++++++++++ maestro-ios/src/main/java/ios/IOSDevice.kt | 5 +++ .../src/main/java/ios/LocalIOSDevice.kt | 4 ++ .../main/java/ios/simctl/SimctlIOSDevice.kt | 4 ++ .../main/java/ios/xctest/XCTestIOSDevice.kt | 4 ++ .../main/java/maestro/orchestra/Commands.kt | 15 ++++++++ .../java/maestro/orchestra/MaestroCommand.kt | 5 ++- .../main/java/maestro/orchestra/Orchestra.kt | 7 ++++ .../orchestra/yaml/YamlFluentCommand.kt | 12 ++++++ .../java/maestro/orchestra/yaml/YamlShake.kt | 18 +++++++++ .../orchestra/yaml/YamlCommandReaderTest.kt | 13 +++++++ .../YamlCommandReaderTest/028_shake.yaml | 3 ++ .../kotlin/maestro/test/drivers/FakeDriver.kt | 9 +++++ .../maestro/test/IntegrationTest.kt.rej | 26 +++++++++++++ .../src/test/resources/122_shake.yaml | 3 ++ 20 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlShake.kt create mode 100644 maestro-orchestra/src/test/resources/YamlCommandReaderTest/028_shake.yaml create mode 100644 maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt.rej create mode 100644 maestro-test/src/test/resources/122_shake.yaml diff --git a/maestro-client/src/main/java/maestro/Driver.kt b/maestro-client/src/main/java/maestro/Driver.kt index 1abcd8fdd9..cf36b5f38e 100644 --- a/maestro-client/src/main/java/maestro/Driver.kt +++ b/maestro-client/src/main/java/maestro/Driver.kt @@ -43,6 +43,8 @@ interface Driver { fun killApp(appId: String) + fun shake() + fun clearAppState(appId: String) fun clearKeychain() diff --git a/maestro-client/src/main/java/maestro/Maestro.kt b/maestro-client/src/main/java/maestro/Maestro.kt index a0cabfb668..41429bf0b0 100644 --- a/maestro-client/src/main/java/maestro/Maestro.kt +++ b/maestro-client/src/main/java/maestro/Maestro.kt @@ -83,6 +83,12 @@ class Maestro( driver.killApp(appId) } + fun shake(){ + LOGGER.info("Shake device") + + driver.shake() + } + fun clearAppState(appId: String) { LOGGER.info("Clearing app state $appId") diff --git a/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt b/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt index c5404b184c..46d43bafa9 100644 --- a/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt +++ b/maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt @@ -235,6 +235,11 @@ class AndroidDriver( } } + override fun shake() { + dadb.shell("emu sensor set acceleration 100:100:100") + dadb.shell("emu sensor set acceleration 0:0:0") + } + override fun clearAppState(appId: String) { metrics.measured("operation", mapOf("command" to "clearAppState", "appId" to appId)) { if (!isPackageInstalled(appId)) { diff --git a/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt b/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt index 672a2c9cdf..5080b4ee41 100644 --- a/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt +++ b/maestro-client/src/main/java/maestro/drivers/IOSDriver.kt @@ -104,6 +104,12 @@ class IOSDriver( } } + override fun shake() { + metrics.measured("operation", mapOf("command" to "shake")) { + iosDevice.shake() + } + } + override fun clearAppState(appId: String) { metrics.measured("operation", mapOf("command" to "clearAppState", "appId" to appId)) { iosDevice.clearAppState(appId) diff --git a/maestro-client/src/main/java/maestro/drivers/WebDriver.kt b/maestro-client/src/main/java/maestro/drivers/WebDriver.kt index cecc2be782..5415a7951e 100644 --- a/maestro-client/src/main/java/maestro/drivers/WebDriver.kt +++ b/maestro-client/src/main/java/maestro/drivers/WebDriver.kt @@ -185,6 +185,10 @@ class WebDriver( stopApp(appId) } + override fun shake() { + // TODO: Implement shake for web + } + override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode { ensureOpen() diff --git a/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt b/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt index 0be37a3eb5..73300a7b60 100644 --- a/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt +++ b/maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt @@ -170,6 +170,44 @@ class XCTestDriverClient( installer.close() } + fun shake(){ + logger.trace("Executing shake gesture on iOS simulator") + + try { + // Create ProcessBuilder for executing AppleScript + val processBuilder = ProcessBuilder( + "osascript", + "-e", """ + tell application "Simulator" + activate + delay 0.5 -- Give simulator time to come to foreground + tell application "System Events" + tell process "Simulator" + -- Access the Hardware menu and click Shake Gesture + click menu item "Shake" of menu "Device" of menu bar 1 + end tell + end tell + end tell + """.trimIndent() + ) + + // Execute the command + val process = processBuilder.start() + val exitCode = process.waitFor() + + if (exitCode != 0) { + val errorStream = process.errorStream.bufferedReader().readText() + logger.error("Failed to execute shake gesture. Exit code: $exitCode, Error: $errorStream") + throw IOException("Failed to execute shake gesture on simulator. Exit code: $exitCode") + } + + logger.trace("Successfully executed shake gesture") + } catch (e: Exception) { + logger.error("Error executing shake gesture", e) + throw XCUITestServerError.UnknownFailure("Failed to execute shake gesture on simulator: ${e.message}") + } + } + fun setPermissions(permissions: Map) { executeJsonRequest("setPermissions", SetPermissionsRequest(permissions)) } diff --git a/maestro-ios/src/main/java/ios/IOSDevice.kt b/maestro-ios/src/main/java/ios/IOSDevice.kt index 1669a8ba28..96c218947d 100644 --- a/maestro-ios/src/main/java/ios/IOSDevice.kt +++ b/maestro-ios/src/main/java/ios/IOSDevice.kt @@ -149,6 +149,11 @@ interface IOSDevice : AutoCloseable { fun eraseText(charactersToErase: Int) + /** + * Shake the device + */ + fun shake() + fun addMedia(path: String) } diff --git a/maestro-ios/src/main/java/ios/LocalIOSDevice.kt b/maestro-ios/src/main/java/ios/LocalIOSDevice.kt index 9684907e9b..ab4d216771 100644 --- a/maestro-ios/src/main/java/ios/LocalIOSDevice.kt +++ b/maestro-ios/src/main/java/ios/LocalIOSDevice.kt @@ -154,6 +154,10 @@ class LocalIOSDevice( xcTestDevice.eraseText(charactersToErase) } + override fun shake(){ + xcTestDevice.shake() + } + override fun addMedia(path: String) { simctlIOSDevice.addMedia(path) } diff --git a/maestro-ios/src/main/java/ios/simctl/SimctlIOSDevice.kt b/maestro-ios/src/main/java/ios/simctl/SimctlIOSDevice.kt index 792131ccc9..3d041a5909 100644 --- a/maestro-ios/src/main/java/ios/simctl/SimctlIOSDevice.kt +++ b/maestro-ios/src/main/java/ios/simctl/SimctlIOSDevice.kt @@ -187,6 +187,10 @@ class SimctlIOSDevice( TODO("Not yet implemented") } + override fun shake(){ + TODO("Not yet implemented") + } + override fun close() { stopScreenRecording() } diff --git a/maestro-ios/src/main/java/ios/xctest/XCTestIOSDevice.kt b/maestro-ios/src/main/java/ios/xctest/XCTestIOSDevice.kt index 91b5506c28..935548ad69 100644 --- a/maestro-ios/src/main/java/ios/xctest/XCTestIOSDevice.kt +++ b/maestro-ios/src/main/java/ios/xctest/XCTestIOSDevice.kt @@ -215,6 +215,10 @@ class XCTestIOSDevice( execute { client.eraseText(charactersToErase, appIds) } } + override fun shake(){ + execute { client.shake() } + } + private fun activeAppId(): String { return execute { val appIds = getInstalledApps() diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt index 5d9ca617aa..50338e54c0 100644 --- a/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt @@ -1090,6 +1090,21 @@ data class ToggleAirplaneModeCommand( } } +data class ShakeCommand( + override val label: String? = null, + override val optional: Boolean = false, + ) : Command { + + override fun description(): String { + return label ?: "Shakes the device" + } + + override fun evaluateScripts(jsEngine: JsEngine): Command { + return this + } +} + + internal fun tapOnDescription(isLongPress: Boolean?, repeat: TapRepeat?): String { return if (isLongPress == true) "Long press" else if (repeat != null) { diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt index fb0d600682..a0d6e2a690 100644 --- a/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt @@ -69,6 +69,7 @@ data class MaestroCommand( val setAirplaneModeCommand: SetAirplaneModeCommand? = null, val toggleAirplaneModeCommand: ToggleAirplaneModeCommand? = null, val retryCommand: RetryCommand? = null, + val shakeCommand: ShakeCommand? = null, ) { constructor(command: Command) : this( @@ -112,7 +113,8 @@ data class MaestroCommand( addMediaCommand = command as? AddMediaCommand, setAirplaneModeCommand = command as? SetAirplaneModeCommand, toggleAirplaneModeCommand = command as? ToggleAirplaneModeCommand, - retryCommand = command as? RetryCommand + retryCommand = command as? RetryCommand, + shakeCommand = command as? ShakeCommand ) fun asCommand(): Command? = when { @@ -157,6 +159,7 @@ data class MaestroCommand( setAirplaneModeCommand != null -> setAirplaneModeCommand toggleAirplaneModeCommand != null -> toggleAirplaneModeCommand retryCommand != null -> retryCommand + shakeCommand != null -> shakeCommand else -> null } diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt index b2aba45244..eefc67a30d 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt @@ -302,6 +302,7 @@ class Orchestra( is SetAirplaneModeCommand -> setAirplaneMode(command) is ToggleAirplaneModeCommand -> toggleAirplaneMode() is RetryCommand -> retryCommand(command, config) + is ShakeCommand -> shakeCommand(command) else -> true }.also { mutating -> if (mutating) { @@ -497,6 +498,12 @@ class Orchestra( return true } + private fun shakeCommand(command: ShakeCommand): Boolean { + maestro.shake() + + return true + } + private fun scrollVerticalCommand(): Boolean { maestro.scrollVertical() return true diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt index 237138120e..4108dbc870 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt @@ -66,6 +66,7 @@ import maestro.orchestra.TapOnPointV2Command import maestro.orchestra.ToggleAirplaneModeCommand import maestro.orchestra.TravelCommand import maestro.orchestra.WaitForAnimationToEndCommand +import maestro.orchestra.ShakeCommand import maestro.orchestra.error.InvalidFlowFile import maestro.orchestra.error.MediaFileNotFound import maestro.orchestra.error.SyntaxError @@ -123,6 +124,7 @@ data class YamlFluentCommand( val setAirplaneMode: YamlSetAirplaneMode? = null, val toggleAirplaneMode: YamlToggleAirplaneMode? = null, val retry: YamlRetryCommand? = null, + val shake: YamlShake? = null, ) { @SuppressWarnings("ComplexMethod") @@ -350,6 +352,12 @@ data class YamlFluentCommand( ) ) + shake != null -> listOf( + MaestroCommand( + ShakeCommand() + ) + ) + clearState != null -> listOf( MaestroCommand( ClearStateCommand( @@ -1028,6 +1036,10 @@ data class YamlFluentCommand( assertNoDefectsWithAI = YamlAssertNoDefectsWithAI() ) + "shake" -> YamlFluentCommand( + shake = YamlShake() + ) + else -> throw SyntaxError("Invalid command: \"$stringCommand\"") } } diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlShake.kt b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlShake.kt new file mode 100644 index 0000000000..6c46e48f25 --- /dev/null +++ b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlShake.kt @@ -0,0 +1,18 @@ +package maestro.orchestra.yaml + +import com.fasterxml.jackson.annotation.JsonCreator + +data class YamlShake( + val label: String? = null, + val optional: Boolean = false, +) { + + companion object { + + @JvmStatic + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + fun parse() = YamlShake() + + } + +} diff --git a/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt b/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt index c6105fe2c9..cb9a29eb75 100644 --- a/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt +++ b/maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt @@ -50,6 +50,7 @@ import maestro.orchestra.TapOnPointV2Command import maestro.orchestra.ToggleAirplaneModeCommand import maestro.orchestra.TravelCommand import maestro.orchestra.WaitForAnimationToEndCommand +import maestro.orchestra.ShakeCommand import maestro.orchestra.error.SyntaxError import maestro.orchestra.yaml.junit.YamlCommandsExtension import maestro.orchestra.yaml.junit.YamlExceptionExtension @@ -653,4 +654,16 @@ internal class YamlCommandReaderTest { ) { assertThat(e.message).contains("Cannot deserialize value of type") } + + @Test + fun shake( + @YamlFile("028_shake.yaml") commands: List, + ) { + assertThat(commands).containsExactly( + ApplyConfigurationCommand(MaestroConfig( + appId = "com.example.app" + )), + ShakeCommand(), + ) + } } diff --git a/maestro-orchestra/src/test/resources/YamlCommandReaderTest/028_shake.yaml b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/028_shake.yaml new file mode 100644 index 0000000000..e53e9c5c06 --- /dev/null +++ b/maestro-orchestra/src/test/resources/YamlCommandReaderTest/028_shake.yaml @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- shake \ No newline at end of file diff --git a/maestro-test/src/main/kotlin/maestro/test/drivers/FakeDriver.kt b/maestro-test/src/main/kotlin/maestro/test/drivers/FakeDriver.kt index b6bb66a2bf..59306e5b6d 100644 --- a/maestro-test/src/main/kotlin/maestro/test/drivers/FakeDriver.kt +++ b/maestro-test/src/main/kotlin/maestro/test/drivers/FakeDriver.kt @@ -198,6 +198,12 @@ class FakeDriver : Driver { events += Event.SwipeElementWithDirection(elementPoint, direction, durationMs) } + override fun shake() { + ensureOpen() + + events.add(Event.Shake) + } + override fun backPress() { ensureOpen() @@ -407,6 +413,9 @@ class FakeDriver : Driver { object HideKeyboard : Event(), UserInteraction + object Shake : Event(), UserInteraction + + data class InputText( val text: String ) : Event(), UserInteraction diff --git a/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt.rej b/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt.rej new file mode 100644 index 0000000000..a237ac4eeb --- /dev/null +++ b/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt.rej @@ -0,0 +1,26 @@ +diff a/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt b/maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt (rejected hunks) +@@ -3279,6 +3279,24 @@ class IntegrationTest { + assertThat(action).isEqualTo(targetAction) + } + ++ @Test ++ fun `Case 122 - Kill app`() { ++ // Given ++ val commands = readCommands("122_shake") ++ ++ val driver = driver { ++ } ++ ++ // When ++ Maestro(driver).use { ++ orchestra(it).runFlow(commands) ++ } ++ ++ // Then ++ // No test failure ++ driver.assertHasEvent(Event.Shake("com.example.app")) ++ } ++ + private fun orchestra( + maestro: Maestro, + ) = Orchestra( diff --git a/maestro-test/src/test/resources/122_shake.yaml b/maestro-test/src/test/resources/122_shake.yaml new file mode 100644 index 0000000000..e53e9c5c06 --- /dev/null +++ b/maestro-test/src/test/resources/122_shake.yaml @@ -0,0 +1,3 @@ +appId: com.example.app +--- +- shake \ No newline at end of file