Skip to content

Add rotate simulator feature for iOS #2431

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ users still use Java 8.

For development, you need to use Java 11 or newer.

If you made changes to the CLI, rebuilt it with `./gradlew :maestro-cli:installDist`. This will generate a startup shell
If you made changes to the CLI, rebuild it with `./gradlew :maestro-cli:installDist`. This will generate a startup shell
script in `./maestro-cli/build/install/maestro/bin/maestro`. Use it instead of globally installed `maestro`.

If you made changes to the iOS XCTest runner app, make sure they are compatible with the version of Xcode used by the GitHub Actions build step. It is currently built using the default version of Xcode listed in the macos runner image [readme][macos_builder_readme].
Expand Down
2 changes: 2 additions & 0 deletions maestro-client/src/main/java/maestro/Driver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,6 @@ interface Driver {
fun setAirplaneMode(enabled: Boolean)

fun setAndroidChromeDevToolsEnabled(enabled: Boolean) = Unit

fun rotateDevice(direction: String)
}
4 changes: 4 additions & 0 deletions maestro-client/src/main/java/maestro/Maestro.kt
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,10 @@ class Maestro(
driver.setAndroidChromeDevToolsEnabled(enabled)
}

fun rotateDevice(direction: String) {
driver.rotateDevice(direction)
}

companion object {

private val LOGGER = LoggerFactory.getLogger(Maestro::class.java)
Expand Down
5 changes: 5 additions & 0 deletions maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,11 @@ class AndroidDriver(
}
}

override fun rotateDevice(direction: String) {
// This command is only needed to rotate an iOS simulator and has no effect here
return
}

override fun setAndroidChromeDevToolsEnabled(enabled: Boolean) {
this.chromeDevToolsEnabled = enabled
}
Expand Down
8 changes: 8 additions & 0 deletions maestro-client/src/main/java/maestro/drivers/IOSDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,14 @@ class IOSDriver(
LOGGER.warn("Airplane mode is not available on iOS simulators")
}

override fun rotateDevice(direction: String) {
metrics.measured("operation", mapOf("command" to "rotateDevice", "appId" to appId)) {
runDeviceCall("rotateDevice") {
iosDevice.rotateDevice(direction)
}
}
}

private fun addMediaToDevice(mediaFile: File) {
metrics.measured("operation", mapOf("command" to "addMediaToDevice")) {
val namedSource = NamedSource(
Expand Down
4 changes: 4 additions & 0 deletions maestro-client/src/main/java/maestro/drivers/WebDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ class WebDriver(
// Do nothing
}

override fun rotateDevice(direction: String) {
// Do nothing
}

companion object {
private const val SCREENSHOT_DIFF_THRESHOLD = 0.005
private const val RETRY_FETCHING_CONTENT_DESCRIPTION = 10
Expand Down
12 changes: 12 additions & 0 deletions maestro-ios-driver/src/main/kotlin/util/LocalSimulatorUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -693,4 +693,16 @@ object LocalSimulatorUtils {
screenRecording.process.waitFor()
return screenRecording.file
}

fun rotateDevice(direction: String) {
runCommand(
listOf(
"osascript",
"-e",
"tell application \"Simulator\" to activate",
"-e",
"tell application \"System Events\" to click menu item \"Rotate ${direction}\" of menu 1 of menu bar item \"Device\" of menu bar 1 of application process \"Simulator\""
)
)
}
}
2 changes: 2 additions & 0 deletions maestro-ios/src/main/java/ios/IOSDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ interface IOSDevice : AutoCloseable {
fun eraseText(charactersToErase: Int)

fun addMedia(path: String)

fun rotateDevice(direction: String)
}

interface IOSScreenRecording : AutoCloseable
4 changes: 4 additions & 0 deletions maestro-ios/src/main/java/ios/LocalIOSDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,8 @@ class LocalIOSDevice(
override fun addMedia(path: String) {
simctlIOSDevice.addMedia(path)
}

override fun rotateDevice(direction: String) {
simctlIOSDevice.rotateDevice(direction)
}
}
4 changes: 4 additions & 0 deletions maestro-ios/src/main/java/ios/simctl/SimctlIOSDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,8 @@ class SimctlIOSDevice(
stopScreenRecording()
}

override fun rotateDevice(direction: String) {
LocalSimulatorUtils.rotateDevice(direction)
}

}
5 changes: 5 additions & 0 deletions maestro-ios/src/main/java/ios/xctest/XCTestIOSDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import maestro.utils.network.XCUITestServerError
import okio.Sink
import okio.buffer
import org.slf4j.LoggerFactory
import util.LocalSimulatorUtils
import xcuitest.XCTestDriverClient
import java.io.InputStream
import java.net.SocketTimeoutException
Expand Down Expand Up @@ -218,6 +219,10 @@ class XCTestIOSDevice(
execute { client.eraseText(charactersToErase, appIds = emptySet()) }
}

override fun rotateDevice(direction: String) {
LocalSimulatorUtils.rotateDevice(direction)
}

private fun activeAppId(): String {
return execute {
val appIds = getInstalledApps()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,22 @@ data class RetryCommand(

}

data class RotateDeviceCommand(
val direction: String? = null,
override val label: String? = null,
override val optional: Boolean,
) : Command {

override fun description(): String {
return label ?: "Rotate device"
}

override fun evaluateScripts(jsEngine: JsEngine): Command {
return this
}

}

data class DefineVariablesCommand(
val env: Map<String, String>,
override val label: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ data class MaestroCommand(
val setAirplaneModeCommand: SetAirplaneModeCommand? = null,
val toggleAirplaneModeCommand: ToggleAirplaneModeCommand? = null,
val retryCommand: RetryCommand? = null,
val rotateDeviceCommand: RotateDeviceCommand? = null,
) {

constructor(command: Command) : this(
Expand Down Expand Up @@ -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,
rotateDeviceCommand = command as? RotateDeviceCommand
)

fun asCommand(): Command? = when {
Expand Down Expand Up @@ -157,6 +159,7 @@ data class MaestroCommand(
setAirplaneModeCommand != null -> setAirplaneModeCommand
toggleAirplaneModeCommand != null -> toggleAirplaneModeCommand
retryCommand != null -> retryCommand
rotateDeviceCommand != null -> rotateDeviceCommand
else -> null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ class Orchestra(
is SetAirplaneModeCommand -> setAirplaneMode(command)
is ToggleAirplaneModeCommand -> toggleAirplaneMode()
is RetryCommand -> retryCommand(command, config)
is RotateDeviceCommand -> rotateDevice(command)
else -> true
}.also { mutating ->
if (mutating) {
Expand Down Expand Up @@ -1254,6 +1255,12 @@ class Orchestra(
}
}

private fun rotateDevice(command: RotateDeviceCommand): Boolean {
maestro.rotateDevice(command.direction ?: "Right")

return true
}

private fun pasteText(): Boolean {
copiedText?.let { maestro.inputText(it) }
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ private val stringCommands = mapOf<String, (JsonLocation) -> YamlFluentCommand>(
_location = location,
assertNoDefectsWithAI = YamlAssertNoDefectsWithAI()
)},
"rotateDevice" to { location -> YamlFluentCommand(
_location = location,
rotateDevice = YamlRotateDevice(direction = "Right")
)}
)

private val allCommands = (stringCommands.keys + objectCommands).distinct()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import maestro.orchestra.PasteTextCommand
import maestro.orchestra.PressKeyCommand
import maestro.orchestra.RepeatCommand
import maestro.orchestra.RetryCommand
import maestro.orchestra.RotateDeviceCommand
import maestro.orchestra.RunFlowCommand
import maestro.orchestra.RunScriptCommand
import maestro.orchestra.ScrollCommand
Expand Down Expand Up @@ -129,6 +130,7 @@ data class YamlFluentCommand(
val setAirplaneMode: YamlSetAirplaneMode? = null,
val toggleAirplaneMode: YamlToggleAirplaneMode? = null,
val retry: YamlRetryCommand? = null,
val rotateDevice: YamlRotateDevice? = null,
@JsonIgnore val _location: JsonLocation,
) {

Expand Down Expand Up @@ -478,6 +480,16 @@ data class YamlFluentCommand(
)
)

rotateDevice != null -> listOf(
MaestroCommand(
RotateDeviceCommand(
rotateDevice.direction,
rotateDevice.label,
rotateDevice.optional
)
)
)

else -> throw SyntaxError("Invalid command: No mapping provided for $this")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package maestro.orchestra.yaml

import com.fasterxml.jackson.annotation.JsonCreator

data class YamlRotateDevice(
val direction: String? = null,
val label: String? = null,
val optional: Boolean = false,
) {
companion object {
@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun parse(rotateDirection: String?) = YamlRotateDevice(
direction = when (rotateDirection?.lowercase()) {
"left" -> "Left"
"right" -> "Right"
else -> "Right"
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@ class FakeDriver : Driver {
this.airplaneMode = enabled
}

override fun rotateDevice(direction: String) {
events.add(Event.RotateDevice(direction))
}

sealed class Event {

data class Tap(
Expand Down Expand Up @@ -483,6 +487,10 @@ class FakeDriver : Driver {
object StartRecording : Event()

object StopRecording : Event()

data class RotateDevice(
val direction: String,
) : Event()
}

interface UserInteraction
Expand Down
23 changes: 23 additions & 0 deletions maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3218,6 +3218,29 @@ class IntegrationTest {
)
}

@Test
fun `Case 120 - Rotate the iOS simulator`() {
// Given
val commands = readCommands("120_rotate_device")

val driver = driver {
}

// When
Maestro(driver).use {
orchestra(it).runFlow(commands)
}

// Then
// No test failure
driver.assertEvents(
listOf(
Event.RotateDevice("Left"),
Event.RotateDevice("Right"),
)
)
}

private fun orchestra(
maestro: Maestro,
) = Orchestra(
Expand Down
6 changes: 6 additions & 0 deletions maestro-test/src/test/resources/120_rotate_device.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
appId: com.example.app
---
- rotateDevice:
direction: "Left"
- rotateDevice:
direction: "Right"
Loading