Skip to content

feat: add ability to switch themes on iOS and Android: setDarkMode & toggleDarkMode #2507

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 5 commits into
base: main
Choose a base branch
from

Conversation

markrickert
Copy link
Contributor

Proposed changes

This is a new feature that adds the ability to set the light/dark mode on iOS and Android platforms.

It adds the following new functionality:

  • setDarkMode - possible values: enabled / disabled
  • toggleDarkMode - swaps between light/dark mode

It achieves this by running commands for iOS/Android:

iOS Android
setDarkMode: enabled xcrun simctl ui booted appearance dark adb shell 'cmd uimode night yes'
setDarkMode: disabled xcrun simctl ui booted appearance light adb shell 'cmd uimode night no'

Possible enhancements

There's a way to change the macOS theme and toggle between light/dark mode from the terminal but it requires you use applescript on the commandline. This would be for the web implementation to toggle back and forth since there's no current web implementation of this feature.

Testing

./gradlew :maestro-test:test && ./gradlew test passes 🟢

I then "ignited" an app that has a default maestro test in it called FavoritePodcast.yaml and replaced the contents with the following, adding calls to setDarkMode and toggleDarkMode

# flow: run the login flow and then navigate to the demo podcast list screen, favorite a podcast, and then switch the list to only be favorites.

appId: ${MAESTRO_APP_ID}
env:
  FAVORITES_TEXT: "Switch on to only show favorites" # en.demoPodcastListScreen.accessibility.switch
onFlowStart:
  - runFlow: ../shared/_OnFlowStart.yaml
---
- runFlow: ../shared/_Login.yaml
- setDarkMode: disabled
- toggleDarkMode
- toggleDarkMode
- tapOn: "Podcast"
- assertVisible: "React Native Radio episodes"
- tapOn:
    text: ${FAVORITES_TEXT}
- setDarkMode: disabled
- assertVisible: "This looks a bit empty"
- tapOn:
    text: ${FAVORITES_TEXT}
    # https://maestro.mobile.dev/troubleshooting/known-issues#android-accidental-double-tap
    retryTapIfNoChange: false
- toggleDarkMode
- repeat:
    times: 2
    commands:
      - scroll
- copyTextFrom:
    text: "RNR .*" # assumes all podcast titles start with RNR
    index: 2 # grab the third one, others might not be fully visible
- toggleDarkMode
- longPressOn: ${maestro.copiedText}
- scrollUntilVisible:
    element:
      text: ${FAVORITES_TEXT}
    direction: UP
    timeout: 50000
    speed: 90
    visibilityPercentage: 100
- tapOn:
    text: ${FAVORITES_TEXT}
- assertVisible: ${maestro.copiedText}

Then to test this, i ran the associated application on the iOS simulator and from this projects' directory i ran:

./maestro record -e MAESTRO_APP_ID=com.foo ../Foo/.maestro/flows/FavoritePodcast.yaml

Here is the resulting video showing the interface swapping back and forth from light to dark:

maestro-record.mp4

Issues fixed

n/a

@markrickert
Copy link
Contributor Author

Adde docs PR to document new feature - mobile-dev-inc/maestro-docs#133

@morganick
Copy link

@markrickert does this handle specifying the device?

@markrickert
Copy link
Contributor Author

@markrickert does this handle specifying the device?

@morganick On iOS, it should pass through the device id and if you don't pass one, it'll use "booted" as a default. I'm unsure how android handles this.

https://github.com/mobile-dev-inc/Maestro/pull/2507/files#diff-25ebfb9b3069b37481b09445ea8c4458fdebdc760b55ef6e9af537f0fc1e9221R518-R527

@morganick
Copy link

@markrickert does this handle specifying the device?

@morganick On iOS, it should pass through the device id and if you don't pass one, it'll use "booted" as a default. I'm unsure how android handles this.

https://github.com/mobile-dev-inc/Maestro/pull/2507/files#diff-25ebfb9b3069b37481b09445ea8c4458fdebdc760b55ef6e9af537f0fc1e9221R518-R527

@markrickert Yeah, I saw the deviceId portion on iOS, but trying to run your commands when I have more than one android device results in adb: more than one device/emulator. It needs the -s <serial-number> for the specified device. I didn't see how this was handled on the android side either.

@markrickert
Copy link
Contributor Author

@morganick I believe i've addressed the problem. I was using the regular shell command when i should have been using dadb.shell to reference the currently active emulator instance.

fd264a5

"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

@Leland-Takamine
Copy link
Contributor

@morganick I believe i've addressed the problem. I was using the regular shell command when i should have been using dadb.shell to reference the currently active emulator instance.

fd264a5

shell method was actually correct - it's just a helper that checks the exit code as well. You'd just pass in the adb shell command without the adb shell part since it's using Dadb.

    private fun shell(command: String): String {
        val response: AdbShellResponse = try {
            dadb.shell(command)
        } catch (e: IOException) {
            throw IOException(command, e)
        }

        if (response.exitCode != 0) {
            throw IOException("$command: ${response.allOutput}")
        }
        return response.output
    }

@@ -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

@@ -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?

value: enabled
label: "Turn on dark mode for testing"
- toggleDarkMode:
label: "Toggle dark mode for testing"
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is the difference between these two commands?

Can't I toggle with reverting the value passed in setDarkMode command? 🤔


// Then
// No test failures
driver.assertNoInteraction()
Copy link
Collaborator

Choose a reason for hiding this comment

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

This feels like incomplete we do want to assert the appearance event here though right?

}

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants