Skip to content

Commit 46b646d

Browse files
authored
Strongly Typed Device Configuration (#3001)
* feat: added deviceLocale and depreciated localeutils * feat: added devicelocale and used that * removed duplicate platform * update code to use new device configurations * added deviceCatalogtest * depreciate locale test * fix platform import * fix test * fix: added cloud devices from response * added sealed MaestroDeviceConfiguration * add more fields to DeviceConfiguration * fix: device catalog device * fix: tests * added web device launch * moved platform inside resolve and avoided try/catch * fix: pick recommended device to take defaults directly * improved device list response. * fix device catalog test * use api for device * feat: added deviceLocale and depreciated localeutils * feat: added devicelocale and used that * removed duplicate platform * update code to use new device configurations * added deviceCatalogtest * depreciate locale test * fix platform import * fix test * fix: added cloud devices from response * added sealed MaestroDeviceConfiguration * add more fields to DeviceConfiguration * fix: device catalog device * fix: tests * added web device launch * moved platform inside resolve and avoided try/catch * fix: pick recommended device to take defaults directly * improved device list response. * fix device catalog test * use api for device * fix: added new flags to device start, and depreaciated --os-version * removed the default list * fix test * reverted os flag discription * fix: added deviceConfiguration to available device * moved orientation and locale inside device * change type name * removed locale utils test * fix tests * moved getMacOSArchitecture to client * fix: added image test + handled CPU_Architecture enum differently * monor fix
1 parent f3dd692 commit 46b646d

File tree

40 files changed

+1227
-452
lines changed

40 files changed

+1227
-452
lines changed

maestro-cli/src/main/java/maestro/cli/command/StartDeviceCommand.kt

Lines changed: 47 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
11
package maestro.cli.command
22

3+
import maestro.device.DeviceCatalog
34
import maestro.cli.App
45
import maestro.cli.CliError
56
import maestro.cli.ShowHelpMixin
67
import maestro.cli.device.DeviceCreateUtil
78
import maestro.device.DeviceService
8-
import maestro.device.Platform
99
import maestro.cli.report.TestDebugReporter
10-
import maestro.cli.util.DeviceConfigAndroid
11-
import maestro.cli.util.DeviceConfigIos
1210
import maestro.cli.util.EnvUtils
13-
import maestro.cli.util.PrintUtils
14-
import maestro.utils.LocaleUtils
15-
import maestro.utils.LocaleValidationAndroidCountryException
16-
import maestro.utils.LocaleValidationAndroidLanguageException
17-
import maestro.utils.LocaleValidationIosException
18-
import maestro.utils.LocaleValidationNotSupportedPlatformException
19-
import maestro.utils.LocaleValidationWrongLocaleFormatException
2011
import picocli.CommandLine
2112
import java.util.concurrent.Callable
2213

@@ -39,16 +30,18 @@ class StartDeviceCommand : Callable<Int> {
3930
order = 0,
4031
names = ["--platform"],
4132
required = true,
42-
description = ["Platforms: android, ios"],
33+
description = ["Platforms: android, ios, web"],
4334
)
4435
private lateinit var platform: String
4536

37+
@Deprecated("Use --device-os instead")
4638
@CommandLine.Option(
4739
order = 1,
40+
hidden = true,
4841
names = ["--os-version"],
4942
description = ["OS version to use:", "iOS: 16, 17, 18", "Android: 28, 29, 30, 31, 33"],
5043
)
51-
private lateinit var osVersion: String
44+
private var osVersion: String? = null
5245

5346
@CommandLine.Option(
5447
order = 2,
@@ -57,8 +50,30 @@ class StartDeviceCommand : Callable<Int> {
5750
)
5851
private var deviceLocale: String? = null
5952

53+
@CommandLine.Option(
54+
order = 3,
55+
names = ["--device-model"],
56+
description = [
57+
"Device model to run against",
58+
"iOS: iPhone-11, iPhone-11-Pro, etc. Run command: xcrun simctl list devicetypes --json | jq -r '.devicetypes[].identifier | split(\".\") | last'\n",
59+
"Android: pixel_6, pixel_7, etc. Run command: avdmanager list device -c"
60+
],
61+
)
62+
private var deviceModel: String? = null
63+
6064
@CommandLine.Option(
6165
order = 4,
66+
names = ["--device-os"],
67+
description = [
68+
"OS version to use:",
69+
"iOS: iOS-16-2, iOS-17-5, iOS-18-2, etc. xcrun simctl list runtimes --json | jq -r '.runtimes[].identifier | split(\".\") | last'\n",
70+
"Android: android-33, android-34, etc. Run command: sdkmanager --list | grep \"system-images\" | awk -F';' '{print \$2}' | sort -u\n"
71+
],
72+
)
73+
private var deviceOs: String? = null
74+
75+
@CommandLine.Option(
76+
order = 5,
6277
names = ["--force-create"],
6378
description = ["Will override existing device if it already exists"],
6479
)
@@ -71,60 +86,26 @@ class StartDeviceCommand : Callable<Int> {
7186
throw CliError("This command is not supported in Windows WSL. You can launch your emulator manually.")
7287
}
7388

74-
val p = Platform.fromString(platform)
75-
?: throw CliError("Unsupported platform $platform. Please specify one of: android, ios")
76-
77-
// default OS version
78-
if (!::osVersion.isInitialized) {
79-
osVersion = when (p) {
80-
Platform.IOS -> DeviceConfigIos.defaultVersion.toString()
81-
Platform.ANDROID -> DeviceConfigAndroid.defaultVersion.toString()
82-
else -> ""
83-
}
84-
}
85-
val o = osVersion.toIntOrNull()
86-
87-
val maestroPlatform = when(p) {
88-
Platform.ANDROID -> maestro.Platform.ANDROID
89-
Platform.IOS -> maestro.Platform.IOS
90-
Platform.WEB -> maestro.Platform.WEB
91-
}
92-
93-
val locale = deviceLocale ?: "en_US"
94-
95-
try {
96-
val (deviceLanguage, deviceCountry) = LocaleUtils.parseLocaleParams(locale, maestroPlatform)
97-
98-
DeviceCreateUtil.getOrCreateDevice(p, o, deviceLanguage, deviceCountry, forceCreate).let { device ->
99-
PrintUtils.message(if (p == Platform.IOS) "Launching simulator..." else "Launching emulator...")
100-
DeviceService.startDevice(
101-
device = device,
102-
driverHostPort = parent?.port
103-
)
104-
}
105-
} catch (e: LocaleValidationIosException) {
106-
val locales = LocaleUtils.IOS_SUPPORTED_LOCALES.joinToString("\n")
107-
throw CliError("$deviceLocale locale is currently not supported by Maestro, please check that it is a valid ISO-639-1 + ISO-3166-1 code. Here is a full list of supported locales:\n" +
108-
"\n" +
109-
locales
110-
)
111-
} catch (e: LocaleValidationAndroidLanguageException) {
112-
val languages = LocaleUtils.ANDROID_SUPPORTED_LANGUAGES.joinToString("\n")
113-
throw CliError("${e.language} language is currently not supported by Maestro, please check that it is a valid ISO-639-1 code. Here is a full list of supported languages:\n" +
114-
"\n" +
115-
languages
116-
)
117-
} catch (e: LocaleValidationAndroidCountryException) {
118-
val countries = LocaleUtils.ANDROID_SUPPORTED_COUNTRIES.joinToString("\n")
119-
throw CliError("${e.country} country is currently not supported by Maestro, please check that it is a valid ISO-3166-1 code. Here is a full list of supported countries:\n" +
120-
"\n" +
121-
countries
122-
)
123-
} catch(e: LocaleValidationWrongLocaleFormatException) {
124-
throw CliError("Wrong device locale format was provided $deviceLocale. A combination of lowercase ISO-639-1 code and uppercase ISO-3166-1 code should be used, i.e. \"de_DE\" for Germany. More info can be found here https://maestro.mobile.dev/")
125-
} catch (e: LocaleValidationNotSupportedPlatformException) {
126-
throw CliError("The feature to set a device locale is not supported by the platform ${p.description}")
127-
}
89+
// Get the device configuration
90+
val maestroDeviceConfiguration = DeviceCatalog.resolve(
91+
platform = platform,
92+
model = deviceModel,
93+
os = deviceOs ?: osVersion,
94+
locale = deviceLocale,
95+
systemArchitecture = EnvUtils.getMacOSArchitecture()
96+
)
97+
98+
// Get/Create the device
99+
val device = DeviceCreateUtil.getOrCreateDevice(
100+
maestroDeviceConfiguration,
101+
forceCreate
102+
)
103+
104+
// Start Device
105+
DeviceService.startDevice(
106+
device = device,
107+
driverHostPort = parent?.port
108+
)
128109

129110
return 0
130111
}

maestro-cli/src/main/java/maestro/cli/device/DeviceCreateUtil.kt

Lines changed: 41 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,128 +3,97 @@ package maestro.cli.device
33
import maestro.device.DeviceService
44
import maestro.device.Device
55
import maestro.device.Platform
6-
76
import maestro.cli.CliError
87
import maestro.cli.util.*
9-
import maestro.device.util.AvdDevice
8+
import maestro.device.DeviceSpec
109

1110
object DeviceCreateUtil {
1211

1312
fun getOrCreateDevice(
14-
platform: Platform,
15-
osVersion: Int? = null,
16-
language: String? = null,
17-
country: String? = null,
13+
deviceSpec: DeviceSpec,
1814
forceCreate: Boolean = false,
1915
shardIndex: Int? = null,
20-
): Device.AvailableForLaunch = when (platform) {
21-
Platform.ANDROID -> getOrCreateAndroidDevice(osVersion, language, country, forceCreate, shardIndex)
22-
Platform.IOS -> getOrCreateIosDevice(osVersion, language, country, forceCreate, shardIndex)
23-
else -> throw CliError("Unsupported platform $platform. Please specify one of: android, ios")
16+
): Device.AvailableForLaunch = when (deviceSpec) {
17+
is DeviceSpec.Android -> getOrCreateAndroidDevice(deviceSpec, forceCreate, shardIndex)
18+
is DeviceSpec.Ios -> getOrCreateIosDevice(deviceSpec, forceCreate, shardIndex)
19+
is DeviceSpec.Web -> Device.AvailableForLaunch(
20+
platform = Platform.WEB,
21+
description = "Chromium Desktop Browser (Experimental)",
22+
modelId = deviceSpec.model,
23+
deviceType = Device.DeviceType.BROWSER,
24+
deviceSpec = deviceSpec,
25+
)
2426
}
2527

2628
fun getOrCreateIosDevice(
27-
version: Int?, language: String?, country: String?, forceCreate: Boolean, shardIndex: Int? = null
29+
deviceSpec: DeviceSpec.Ios, forceCreate: Boolean, shardIndex: Int? = null
2830
): Device.AvailableForLaunch {
29-
@Suppress("NAME_SHADOWING") val version = version ?: DeviceConfigIos.defaultVersion
30-
if (version !in DeviceConfigIos.versions) {
31-
throw CliError("Provided iOS version is not supported. Please use one of ${DeviceConfigIos.versions}")
32-
}
33-
34-
val runtime = DeviceConfigIos.runtimes[version]
35-
if (runtime == null) {
36-
throw CliError("Provided iOS runtime is not supported $runtime")
37-
}
38-
39-
val deviceName = DeviceConfigIos.generateDeviceName(version) + shardIndex?.let { "_${it + 1}" }.orEmpty()
40-
val device = DeviceConfigIos.device
41-
4231
// check connected device
43-
if (DeviceService.isDeviceConnected(deviceName, Platform.IOS) != null && shardIndex == null && !forceCreate) {
44-
throw CliError("A device with name $deviceName is already connected")
32+
if (DeviceService.isDeviceConnected(deviceSpec.deviceName, Platform.IOS) != null && shardIndex == null && !forceCreate) {
33+
throw CliError("A device with name ${deviceSpec.deviceName} is already connected")
4534
}
4635

4736
// check existing device
48-
val existingDeviceId = DeviceService.isDeviceAvailableToLaunch(deviceName, Platform.IOS)?.let {
37+
val existingDeviceId = DeviceService.isDeviceAvailableToLaunch(deviceSpec.deviceName, Platform.IOS)?.let {
4938
if (forceCreate) {
5039
DeviceService.deleteIosDevice(it.modelId)
5140
null
5241
} else it.modelId
5342
}
5443

55-
if (existingDeviceId != null) PrintUtils.message("Using existing device $deviceName (${existingDeviceId}).")
56-
else PrintUtils.message("Attempting to create iOS simulator: $deviceName ")
57-
44+
if (existingDeviceId != null) PrintUtils.message("Using existing device ${deviceSpec.deviceName} (${existingDeviceId}).")
45+
else PrintUtils.message("Attempting to create iOS simulator: ${deviceSpec.deviceName} ")
5846

5947
val deviceUUID = try {
60-
existingDeviceId ?: DeviceService.createIosDevice(deviceName, device, runtime).toString()
48+
existingDeviceId ?: DeviceService.createIosDevice(deviceSpec.deviceName, deviceSpec.model, deviceSpec.os).toString()
6149
} catch (e: IllegalStateException) {
6250
val error = e.message ?: ""
6351
if (error.contains("Invalid runtime")) {
6452
val msg = """
65-
Required runtime to create the simulator is not installed: $runtime
66-
53+
Required runtime to create the simulator is not installed: ${deviceSpec.os}
54+
6755
To install additional iOS runtimes checkout this guide:
6856
* https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes
6957
""".trimIndent()
7058
throw CliError(msg)
7159
} else if (error.contains("xcrun: error: unable to find utility \"simctl\"")) {
7260
val msg = """
7361
The xcode-select CLI tools are not installed, install with xcode-select --install
74-
62+
7563
If the xcode-select CLI tools are already installed, the path may be broken. Try
7664
running sudo xcode-select -r to repair the path and re-run this command
7765
""".trimIndent()
7866
throw CliError(msg)
7967
} else if (error.contains("Invalid device type")) {
80-
throw CliError("Device type $device is either not supported or not found.")
68+
throw CliError("Device type ${deviceSpec.model} is either not supported or not found.")
8169
} else {
8270
throw CliError(error)
8371
}
8472
}
8573

86-
if (existingDeviceId == null) PrintUtils.message("Created simulator with name $deviceName and UUID $deviceUUID")
74+
if (existingDeviceId == null) PrintUtils.message("Created simulator with name ${deviceSpec.deviceName} and UUID $deviceUUID")
8775

8876
return Device.AvailableForLaunch(
8977
modelId = deviceUUID,
90-
description = deviceName,
78+
description = deviceSpec.deviceName,
9179
platform = Platform.IOS,
92-
language = language,
93-
country = country,
94-
deviceType = Device.DeviceType.SIMULATOR
80+
deviceType = Device.DeviceType.SIMULATOR,
81+
deviceSpec = deviceSpec,
9582
)
96-
9783
}
9884

9985
fun getOrCreateAndroidDevice(
100-
version: Int?, language: String?, country: String?, forceCreate: Boolean, shardIndex: Int? = null
86+
deviceSpec: DeviceSpec.Android, forceCreate: Boolean, shardIndex: Int? = null
10187
): Device.AvailableForLaunch {
102-
@Suppress("NAME_SHADOWING") val version = version ?: DeviceConfigAndroid.defaultVersion
103-
if (version !in DeviceConfigAndroid.versions) {
104-
throw CliError("Provided Android version is not supported. Please use one of ${DeviceConfigAndroid.versions}")
105-
}
106-
107-
val architecture = EnvUtils.getMacOSArchitecture()
108-
val pixels = DeviceService.getAvailablePixelDevices()
109-
val pixel = DeviceConfigAndroid.choosePixelDevice(pixels) ?: AvdDevice("-1", "Pixel 6", "pixel_6")
110-
111-
val config = try {
112-
DeviceConfigAndroid.createConfig(version, pixel, architecture)
113-
} catch (e: IllegalStateException) {
114-
throw CliError(e.message ?: "Unable to create android device config")
115-
}
116-
117-
val systemImage = config.systemImage
118-
val deviceName = config.deviceName + shardIndex?.let { "_${it + 1}" }.orEmpty()
119-
88+
val systemImage = deviceSpec.emulatorImage
12089
// check connected device
121-
if (DeviceService.isDeviceConnected(deviceName, Platform.ANDROID) != null && shardIndex == null && !forceCreate)
122-
throw CliError("A device with name $deviceName is already connected")
90+
if (DeviceService.isDeviceConnected(deviceSpec.deviceName, Platform.ANDROID) != null && shardIndex == null && !forceCreate)
91+
throw CliError("A device with name ${deviceSpec.deviceName} is already connected")
12392

12493
// existing device
12594
val existingDevice =
12695
if (forceCreate) null
127-
else DeviceService.isDeviceAvailableToLaunch(deviceName, Platform.ANDROID)?.modelId
96+
else DeviceService.isDeviceAvailableToLaunch(deviceSpec.deviceName, Platform.ANDROID)?.modelId
12897

12998
// dependencies
13099
if (existingDevice == null && !DeviceService.isAndroidSystemImageInstalled(systemImage)) {
@@ -150,32 +119,30 @@ object DeviceCreateUtil {
150119
}
151120
}
152121

153-
if (existingDevice != null) PrintUtils.message("Using existing device $deviceName.")
154-
else PrintUtils.message("Attempting to create Android emulator: $deviceName ")
122+
if (existingDevice != null) PrintUtils.message("Using existing device ${deviceSpec.deviceName}.")
123+
else PrintUtils.message("Attempting to create Android emulator: ${deviceSpec.deviceName} ")
155124

156125
val deviceLaunchId = try {
157126
existingDevice ?: DeviceService.createAndroidDevice(
158-
deviceName = config.deviceName,
159-
device = config.device,
160-
systemImage = config.systemImage,
161-
tag = config.tag,
162-
abi = config.abi,
127+
deviceName = deviceSpec.deviceName,
128+
device = deviceSpec.model,
129+
systemImage = systemImage,
130+
tag = deviceSpec.tag,
131+
abi = deviceSpec.cpuArchitecture.value,
163132
force = forceCreate,
164-
shardIndex = shardIndex,
165133
)
166134
} catch (e: IllegalStateException) {
167135
throw CliError("${e.message}")
168136
}
169137

170-
if (existingDevice == null) PrintUtils.message("Created Android emulator: $deviceName ($systemImage)")
138+
if (existingDevice == null) PrintUtils.message("Created Android emulator: ${deviceSpec.deviceName} ($systemImage)")
171139

172140
return Device.AvailableForLaunch(
173141
modelId = deviceLaunchId,
174142
description = deviceLaunchId,
175143
platform = Platform.ANDROID,
176-
language = language,
177-
country = country,
178144
deviceType = Device.DeviceType.EMULATOR,
145+
deviceSpec = deviceSpec,
179146
)
180147
}
181148
}

maestro-cli/src/main/java/maestro/cli/device/PickDeviceInteractor.kt

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ object PickDeviceInteractor {
2828
var result: Device = pickedDevice
2929

3030
if (result is Device.AvailableForLaunch) {
31-
when (result.platform) {
31+
when (result.deviceSpec.platform) {
3232
Platform.ANDROID -> PrintUtils.message("Launching Android emulator...")
3333
Platform.IOS -> PrintUtils.message("Launching iOS simulator...")
3434
Platform.WEB -> PrintUtils.message("Launching ${result.description}")
@@ -83,18 +83,8 @@ object PickDeviceInteractor {
8383
when(input) {
8484
"1" -> {
8585
PrintUtils.clearConsole()
86-
val options = PickDeviceView.requestDeviceOptions(platform)
87-
if (options.platform == Platform.WEB) {
88-
return Device.AvailableForLaunch(
89-
platform = Platform.WEB,
90-
description = "Chromium Desktop Browser (Experimental)",
91-
modelId = "chromium",
92-
language = null,
93-
country = null,
94-
deviceType = Device.DeviceType.BROWSER
95-
)
96-
}
97-
return DeviceCreateUtil.getOrCreateDevice(options.platform, options.osVersion, null, null, options.forceCreate)
86+
val maestroDeviceConfiguration = PickDeviceView.requestDeviceOptions(platform)
87+
return DeviceCreateUtil.getOrCreateDevice(maestroDeviceConfiguration, false)
9888
}
9989
"2" -> {
10090
PrintUtils.clearConsole()

0 commit comments

Comments
 (0)