Originally published on shotfleet.com.
You wrote a Maestro flow for your app and now want the same screens in German, Japanese and Arabic, for screenshots or for a smoke test. The docs are short on this, and the failure mode is quiet: every run goes green and the images are in English.
I build a tool around this problem (shotfleet, which comes up in the last three sections). Everything before that is plain Maestro and adb.
What the docs say
Maestro's locale page says there is "no place within a Flow itself" (a launchApp or config.yaml) to define the locale. The documented route is the --device-locale flag on maestro start-device (and maestro cloud), not on maestro test, combined with tags:
maestro start-device --platform android --device-locale fr_FR
maestro test --include-tags french .maestro/
That works, and it changes the whole device. For N languages you start N devices in turn, or restart one. It also means the language lives in your shell script and tags, not in the flow.
A per-app alternative: no device change
You can also set the language for your app only, and keep the device as it is.
iOS: launch arguments
launchApp takes an arguments map of key-value pairs that are passed to the app as launch arguments. iOS reads AppleLanguages and AppleLocale from them, so one flow can take the language as a variable:
# flow.yaml (no env: default in the header, see trap 1)
appId: com.example.myapp
---
- launchApp:
clearState: true
arguments:
AppleLanguages: "(${APP_LANG})"
AppleLocale: "${APP_LOCALE}"
- tapOn:
id: onboarding_next
- takeScreenshot: shots/${APP_LANG}/02_onboarding
I ran this on Maestro 2.0.10 against an iOS 26.4 simulator. The tapOn before the screenshot matters: when I put a takeScreenshot straight after launchApp, the image showed the iOS home screen, not the app, in 4 of 4 runs (with and without clearState; the Mac was under heavy load at the time). Putting extendedWaitUntil with visible: id: next first gave the app. Wait for something on screen before the first screenshot.
Everything here was run on Maestro 2.0.10 only. Newer versions can differ: where takeScreenshot writes is one thing to check on yours.
Run it once per language, with the variables after test:
for pair in en:en_US de:de_DE ja:ja_JP; do
maestro --device "$UDID" test \
-e APP_LANG="${pair%%:*}" -e APP_LOCALE="${pair##*:}" flow.yaml
done
Android: per-app locales (Android 13, API 33 and up)
Android 13 added per-app language preferences. From the outside you set one with a shell command, before the flow starts:
adb -s emulator-5554 shell pm clear com.example.myapp
adb -s emulator-5554 shell cmd locale set-app-locales com.example.myapp --locales de
Then run the same flow without clearState, because of trap 3. To keep one file for both platforms, put the iOS launchApp in a runFlow with when: platform: iOS and the Android one in when: platform: Android.
The selectors
A flow that says tapOn: "Next" fails in German. Two ways out: tap by id: (accessibility identifier on iOS, resource id on Android), which does not change with the language, or write the translated text per language. If your app has ids on everything you tap, you are done and need nothing else in this article.
Four traps that give you English by mistake
1. A default in the flow header beats the command line. One developer documented this with Maestro 2.10, and I reproduced it on 2.0.10 on iOS: the flow had env: { LOCALE: en } as a default, and every "Portuguese" and "Spanish" run still came out in English. He fixed it by removing the default from the header, and he caught it only by looking at the images (PR #216). Maestro's parameters page says that constants defined in a subflow override same-name parameters from the parent. In my run the file even landed in the en folder although I passed -e APP_LANG=de. Do not put a default for the language in the header. Pass the language only with -e, and look at the first image of each run.
2. -e goes after test. In the same PR the iOS script put -e before test, and it failed while looking like it passed. On Maestro 2.0.10 I got Unknown options: '-e', 'APP_LANG=de', the usage text and exit code 2, so it only looks like a pass if your script ignores the exit code. The order is maestro --device X test -e KEY=VALUE flow.yaml.
3. On Android, clearing state clears the language. clearState wipes the app's data, and the per-app language goes with it. Clear first with pm clear, set the language, then launch without clearState. Otherwise you get the system language back.
4. Parallel runs on Android. Two separate maestro test processes on two devices on one Mac were unreliable for me on Maestro 2.0.10. With two emulators and two maestro test processes started together, one of the two failed in both attempts (a TimeoutException, or an exception on Maestro's own log folder), while each passed alone. A Maestro process listened on TCP 7001 while it ran, which is my guess for the shared resource; I did not prove it. Run one device at a time, or use one run that shards across devices (--shard-split, which I did not test), and check the images of both devices.
Two smaller ones. System permission dialogs are drawn by the OS in the device language, which stays English while your app is German (I measured this on iOS 26.4 with Maps launched in German; I did not test Android), so write those taps in English. launchApp also takes a permissions setting (see its docs page); I did not test it against a permission dialog. And long languages push buttons off the screen: use scrollUntilVisible before the tap, with direction: DOWN (it happened to me: a Next button nobody could reach in four languages).
Read the images, not the exit code
All four traps pass. The only way to catch them is to look at the output, or to have something look for you: shotfleet check shots/ --app path/to/MyApp.app reads each image back and reports wrong-language and duplicate images. It is free (no licence, no account) and works on any folder of screenshots, fastlane's too (how to check a fastlane snapshot folder).
Where shotfleet fits
Doing all of the above by hand for 18 languages on two platforms is a lot of scripting, and that is the work shotfleet does. It reads the translations compiled into your build and rewrites every text selector in your English flow for each language, so tapOn: "Next" becomes Weiter in German, or (Nächste|Weiter) if your app has two translations for it. It runs the languages in parallel on simulators and emulators and checks each image. On my own app, 18 languages by 4 screens (72 screenshots) took 9 minutes on iOS (four simulators in parallel) and 15 on Android, on one Mac. That is one app and Maestro 2.0.10, so treat it as a data point. run is free for 2 languages; a licence unlocks every language and export: $29 for the first 100 buyers, then $49 once, with a year of updates and a 14-day refund.
When not to use shotfleet
If your app has ids on everything you tap, three or four languages, and one platform, the loop above is enough. If you test on real devices or in a device cloud, shotfleet does not help; it runs simulators and emulators on your Mac. It needs an Apple silicon Mac, Maestro 2.x and Java 17+, and Android needs API 33+ emulators. It makes no frames or captions and does not upload to the stores. For Flutter and React Native it works from your translation files. I have run that end to end on one real Flutter app (wger) and one real Expo app (the obytes template), on iOS and Android, and not yet on any other.
Disclosure: I made shotfleet and it is paid software. The check command and 2 languages per run are free; the full version is $29 for the first 100 buyers, then $49 once. Everything in the guide above works without it.
Top comments (0)