Skip to content
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: 2 additions & 0 deletions e2e/workspaces/demo_app/commands_tour.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ env:

# TODO: setAirplaneMode

# TODO: setDarkMode

# TODO: setLocation

# TODO: startRecording
Expand Down
4 changes: 4 additions & 0 deletions maestro-client/src/main/java/maestro/Driver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,9 @@ interface Driver {

fun setAirplaneMode(enabled: Boolean)

fun isDarkModeEnabled(): Boolean

fun setDarkMode(enabled: Boolean)

fun setAndroidChromeDevToolsEnabled(enabled: Boolean) = Unit
}
8 changes: 8 additions & 0 deletions maestro-client/src/main/java/maestro/Maestro.kt
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,14 @@ class Maestro(
driver.setAirplaneMode(enabled)
}

fun isDarkModeEnabled(): Boolean {
return driver.isDarkModeEnabled()
}

fun setDarkModeState(enabled: Boolean) {
driver.setDarkMode(enabled)
}

fun setAndroidChromeDevToolsEnabled(enabled: Boolean) {
driver.setAndroidChromeDevToolsEnabled(enabled)
}
Expand Down
17 changes: 17 additions & 0 deletions maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,23 @@ class AndroidDriver(
}
}

override fun isDarkModeEnabled(): Boolean {
return metrics.measured("operation", mapOf("command" to "isDarkModeEnabled")) {
when (val result = shell("cmd uimode night").trim()) {
"Night mode: no" -> false
"Night mode: yes" -> true
else -> throw IllegalStateException("Received invalid response while trying to read dark mode state: $result")
}
}
}

override fun setDarkMode(enabled: Boolean) {
metrics.measured("operation", mapOf("command" to "setDarkMode", "enabled" to enabled.toString())) {
val value = if (enabled) "yes" else "no"
shell("cmd uimode night $value")
}
}

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

override fun isDarkModeEnabled(): Boolean {
val deviceId = iosDevice.deviceId ?: "booted"
return XCRunnerCLIUtils.isDarkModeEnabled(deviceId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to do this via xcuitest? We're trying to move away from simctl commands since they won't work for physical devices

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look into this tomorrow to see if there's a way to do it. Not sure at the moment.

Copy link
Contributor Author

@markrickert markrickert May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Leland-Takamine I found this reference already inside the repo's source code, but i have no idea how i would go about using it 🤷 https://github.com/mobile-dev-inc/Maestro/blob/main/maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIDevice.h#L18-L22

Edit: Also found this SO answer that indicates we might be able to just do:

if #available(iOS 15.0, *) {
    XCUIDevice.shared.appearance = .dark
}

But this is my first time ever looking at XCUIDevice so i have no idea how i'd implement that 😬

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @markrickert thanks for contribution, I think this is would be a new endpoint in our XCUITest driver here:

https://github.com/mobile-dev-inc/Maestro/tree/main/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers

You can check any one handler there for example. On implementation we would receive the appearence and then as you shared:

switch requestBody.appearance.lowercased() {
   case "dark":
          XCUIDevice.shared.appearance = .dark
   case "light":
          XCUIDevice.shared.appearance = .light
   default: return AppError(type: .precondition, message: "Invalid appearance value. Use 'dark' or 'light'").httpResponse
}
            

And this endpoint would be reached from XCTest client here in maestro: https://github.com/mobile-dev-inc/Maestro/blob/main/maestro-ios/src/main/java/ios/xctest/XCTestIOSDevice.kt

}

override fun setDarkMode(enabled: Boolean) {
val deviceId = iosDevice.deviceId ?: "booted"
XCRunnerCLIUtils.setDarkMode(deviceId, enabled)
}

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

override fun isDarkModeEnabled(): Boolean {
return false;
}

override fun setDarkMode(enabled: Boolean) {
// Do nothing
}

companion object {
private const val SCREENSHOT_DIFF_THRESHOLD = 0.005
private const val RETRY_FETCHING_CONTENT_DESCRIPTION = 10
Expand Down
Binary file modified maestro-client/src/main/resources/maestro-app.apk
Binary file not shown.
23 changes: 22 additions & 1 deletion maestro-ios-driver/src/main/kotlin/util/XCRunnerCLIUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,25 @@ object XCRunnerCLIUtils {
params = mapOf("TEST_RUNNER_PORT" to port.toString())
)
}
}

fun isDarkModeEnabled(deviceId: String): Boolean {
val process = Runtime.getRuntime().exec(arrayOf("bash", "-c", "xcrun simctl ui $deviceId appearance"))
val appearance = String(process.inputStream.readBytes())

return appearance.trim() == "dark"
}

fun setDarkMode(deviceId: String, enabled: Boolean): Process {
val changeTo = if (enabled) "dark" else "light"
return CommandLineUtils.runCommand(
listOf(
"xcrun",
"simctl",
"ui",
deviceId,
"appearance",
changeTo
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,39 @@ data class ToggleAirplaneModeCommand(
}
}

enum class DarkModeValue {
Enable,
Disable,
}

data class SetDarkModeCommand(
val value: DarkModeValue,
override val label: String? = null,
override val optional: Boolean = false,
) : Command {
override val originalDescription: String
get() = when (value) {
DarkModeValue.Enable -> "Enable dark mode"
DarkModeValue.Disable -> "Disable dark mode"
}

override fun evaluateScripts(jsEngine: JsEngine): Command {
return this
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be allowing setting this variable with js? Not sure. Like runtime through JS. If yes then we might want to handle this.

}
}

data class ToggleDarkModeCommand(
override val label: String? = null,
override val optional: Boolean = false,
) : Command {
override val originalDescription: String
get() = "Toggle dark mode"

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ data class MaestroCommand(
val addMediaCommand: AddMediaCommand? = null,
val setAirplaneModeCommand: SetAirplaneModeCommand? = null,
val toggleAirplaneModeCommand: ToggleAirplaneModeCommand? = null,
val setDarkModeCommand: SetDarkModeCommand? = null,
val toggleDarkModeCommand: ToggleDarkModeCommand? = null,
val retryCommand: RetryCommand? = null,
) {

Expand Down Expand Up @@ -112,6 +114,8 @@ data class MaestroCommand(
addMediaCommand = command as? AddMediaCommand,
setAirplaneModeCommand = command as? SetAirplaneModeCommand,
toggleAirplaneModeCommand = command as? ToggleAirplaneModeCommand,
setDarkModeCommand = command as? SetDarkModeCommand,
toggleDarkModeCommand = command as? ToggleDarkModeCommand,
retryCommand = command as? RetryCommand
)

Expand Down Expand Up @@ -156,6 +160,8 @@ data class MaestroCommand(
addMediaCommand != null -> addMediaCommand
setAirplaneModeCommand != null -> setAirplaneModeCommand
toggleAirplaneModeCommand != null -> toggleAirplaneModeCommand
setDarkModeCommand != null -> setDarkModeCommand
toggleDarkModeCommand != null -> toggleDarkModeCommand
retryCommand != null -> retryCommand
else -> null
}
Expand Down
16 changes: 16 additions & 0 deletions maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ class Orchestra(
is AddMediaCommand -> addMediaCommand(command.mediaPaths)
is SetAirplaneModeCommand -> setAirplaneMode(command)
is ToggleAirplaneModeCommand -> toggleAirplaneMode()
is SetDarkModeCommand -> setDarkMode(command)
is ToggleDarkModeCommand -> toggleDarkMode()
is RetryCommand -> retryCommand(command, config)
else -> true
}.also { mutating ->
Expand All @@ -349,6 +351,20 @@ class Orchestra(
return true
}

private fun setDarkMode(command: SetDarkModeCommand): Boolean {
when (command.value) {
DarkModeValue.Enable -> maestro.setDarkModeState(true)
DarkModeValue.Disable -> maestro.setDarkModeState(false)
}

return true
}

private fun toggleDarkMode(): Boolean {
maestro.setDarkModeState(!maestro.isDarkModeEnabled())
return true
}

private fun travelCommand(command: TravelCommand): Boolean {
Traveller.travel(
maestro = maestro,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ private val stringCommands = mapOf<String, (JsonLocation) -> YamlFluentCommand>(
_location = location,
toggleAirplaneMode = YamlToggleAirplaneMode()
)},
"toggleDarkMode" to { location -> YamlFluentCommand(
_location = location,
toggleDarkMode = YamlToggleDarkMode()
)},
"assertNoDefectsWithAI" to { location -> YamlFluentCommand(
_location = location,
assertNoDefectsWithAI = YamlAssertNoDefectsWithAI()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import maestro.orchestra.RunScriptCommand
import maestro.orchestra.ScrollCommand
import maestro.orchestra.ScrollUntilVisibleCommand
import maestro.orchestra.SetAirplaneModeCommand
import maestro.orchestra.SetDarkModeCommand
import maestro.orchestra.SetLocationCommand
import maestro.orchestra.StartRecordingCommand
import maestro.orchestra.StopAppCommand
Expand All @@ -65,6 +66,7 @@ import maestro.orchestra.TakeScreenshotCommand
import maestro.orchestra.TapOnElementCommand
import maestro.orchestra.TapOnPointV2Command
import maestro.orchestra.ToggleAirplaneModeCommand
import maestro.orchestra.ToggleDarkModeCommand
import maestro.orchestra.TravelCommand
import maestro.orchestra.WaitForAnimationToEndCommand
import maestro.orchestra.error.InvalidFlowFile
Expand Down Expand Up @@ -128,6 +130,8 @@ data class YamlFluentCommand(
val addMedia: YamlAddMedia? = null,
val setAirplaneMode: YamlSetAirplaneMode? = null,
val toggleAirplaneMode: YamlToggleAirplaneMode? = null,
val setDarkMode: YamlSetDarkMode? = null,
val toggleDarkMode: YamlToggleDarkMode? = null,
val retry: YamlRetryCommand? = null,
@JsonIgnore val _location: JsonLocation,
) {
Expand Down Expand Up @@ -478,6 +482,25 @@ data class YamlFluentCommand(
)
)

setDarkMode != null -> listOf(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder are there cases where it is useful to set appearance mid flow execution?

Is this more useful as a command or flow based or upload level config?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a use-case for mid-flow. But given the JS comment later on down, I think I like the idea of being able to do it very early in execution, explicitly as part of your test setup, perhaps calling this before launchApp.

MaestroCommand(
SetDarkModeCommand(
setDarkMode.value,
setDarkMode.label,
setDarkMode.optional
)
)
)

toggleDarkMode != null -> listOf(
MaestroCommand(
ToggleDarkModeCommand(
toggleDarkMode.label,
toggleDarkMode.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,72 @@
package maestro.orchestra.yaml

import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.TreeNode
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import maestro.orchestra.DarkModeValue

@JsonDeserialize(using = YamlSetDarkModeDeserializer::class)
data class YamlSetDarkMode(
val value: DarkModeValue,
val label: String? = null,
val optional: Boolean = false,
) {
companion object {
@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun parse(value: DarkModeValue): YamlSetDarkMode {
return YamlSetDarkMode(value)
}
}
}

class YamlSetDarkModeDeserializer : JsonDeserializer<YamlSetDarkMode>() {

override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): YamlSetDarkMode {
val mapper = (parser.codec as ObjectMapper)
val root: TreeNode = mapper.readTree(parser)
val input = root.fieldNames().asSequence().toList()
val label = getLabel(root)
when {
input.contains("value") -> {
val parsedValue = root.get("value").toString().replace("\"", "")
val returnValue = when (parsedValue) {
"enabled" -> DarkModeValue.Enable
"disabled" -> DarkModeValue.Disable
else -> throwInvalidInputException(input)
}
return YamlSetDarkMode(returnValue, label)
}
(root.isValueNode && root.toString().contains("enabled")) -> {
return YamlSetDarkMode(DarkModeValue.Enable, label)
}
(root.isValueNode && root.toString().contains("disabled")) -> {
return YamlSetDarkMode(DarkModeValue.Disable, label)
}
else -> throwInvalidInputException(input)
}
}

private fun throwInvalidInputException(input: List<String>): Nothing {
throw IllegalArgumentException(
"setDarkMode command takes either: \n" +
"\t1. enabled: To enable dark mode\n" +
"\t2. disabled: To disable dark mode\n" +
"\t3. value: To set dark mode to a specific value (enabled or disabled) \n" +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was a bit confused about this line

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was copying the airplane mode function as it allows this syntax:

- setDarkMode enabled

or

- setDarkMode
    value: enabled
    label: "Something"

I'm definitely open to suggestions on how to improve this!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah I think the syntax makes sense. The 3rd line of the error message is what was confusing me - seems redundant?

1. enabled: To enable dark mode
2. disabled: To disable dark mode
3. value: To set dark mode to a specific value (enabled or disabled)

Copy link
Contributor

@Leland-Takamine Leland-Takamine May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh ok I think I understand now. Maybe it would be clearer to show those two styles of syntax as examples in the error message

"It seems you provided invalid input with: $input"
)
}

private fun getLabel(root: TreeNode): String? {
return if (root.path("label").isMissingNode) {
null
} else {
root.path("label").toString().replace("\"", "")
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package maestro.orchestra.yaml

data class YamlToggleDarkMode(
val label: String? = null,
val optional: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import maestro.SwipeDirection
import maestro.TapRepeat
import maestro.orchestra.AddMediaCommand
import maestro.orchestra.AirplaneValue
import maestro.orchestra.DarkModeValue
import maestro.orchestra.ApplyConfigurationCommand
import maestro.orchestra.AssertConditionCommand
import maestro.orchestra.BackPressCommand
Expand Down Expand Up @@ -39,6 +40,7 @@ import maestro.orchestra.RunScriptCommand
import maestro.orchestra.ScrollCommand
import maestro.orchestra.ScrollUntilVisibleCommand
import maestro.orchestra.SetAirplaneModeCommand
import maestro.orchestra.SetDarkModeCommand
import maestro.orchestra.SetLocationCommand
import maestro.orchestra.StartRecordingCommand
import maestro.orchestra.StopAppCommand
Expand All @@ -48,6 +50,7 @@ import maestro.orchestra.TakeScreenshotCommand
import maestro.orchestra.TapOnElementCommand
import maestro.orchestra.TapOnPointV2Command
import maestro.orchestra.ToggleAirplaneModeCommand
import maestro.orchestra.ToggleDarkModeCommand
import maestro.orchestra.TravelCommand
import maestro.orchestra.WaitForAnimationToEndCommand
import maestro.orchestra.error.SyntaxError
Expand Down Expand Up @@ -425,6 +428,13 @@ internal class YamlCommandReaderTest {
ToggleAirplaneModeCommand(
label = "Toggle airplane mode for testing"
),
SetDarkModeCommand(
value = DarkModeValue.Enable,
label = "Turn on dark mode for testing"
),
ToggleDarkModeCommand(
label = "Toggle dark mode for testing"
),
RepeatCommand(
condition = Condition(visible = ElementSelector(textRegex = "Some important text")),
commands = listOf(
Expand Down
Loading
Loading