Skip to content

Commit b4c88bf

Browse files
authored
refactor: slim DeviceSpec to required fields + sparse serialization (#3170)
## Problem DeviceSpec required callers to provide values that have permanent, never-changing defaults (locale, cpuArchitecture, etc.) and persisted a handful of fields that are pure derivations (osVersion, deviceName, tag, emulatorImage). The result: verbose, noisy payloads, lots of unnecessary stored values in the DB, and confusion about which fields actually carry intent. It also mixed two different concerns: provisioning identity (what device to boot) and driver-level runtime config (how to configure the driver after boot). Driver-level config already lives in WorkspaceConfig, where the CLI has always read it from; only the cloud worker was pulling it from DeviceSpec. Separately, DeviceSpecRequest was a parallel nullable-everything hierarchy that existed only so fromRequest() could magically fill in defaults — indirection without a clear reason to exist now that Kotlin constructor defaults do the same thing. ## Approach - DeviceSpec becomes provisioning identity only: platform, model, os, locale, plus cpuArchitecture on Android. Driver-level fields (disableAnimations, snapshotKeyHonorModalViews, orientation) are removed — consumers read them from WorkspaceConfig.platform instead. - Require only platform + model + os from callers. Everything else has a sensible default in the primary constructor. Derived values (osVersion, deviceName, tag, emulatorImage) become computed get() properties — not stored, not serialized. - Sparse JSON serialization: the wire format contains only the platform discriminator, the required fields, and any optional field whose runtime value differs from the constructor default. Single source of truth for defaults = the constructor signature, read via Kotlin reflection. - Named DEFAULT constant per subtype (DeviceSpec.Android.DEFAULT, etc.) for call sites that just want a reasonable device.
1 parent d727ed0 commit b4c88bf

13 files changed

Lines changed: 484 additions & 238 deletions

File tree

maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import maestro.orchestra.validation.AppValidator
4242
import maestro.orchestra.validation.WorkspaceValidationException
4343
import maestro.orchestra.validation.WorkspaceValidator
4444
import maestro.device.DeviceSpec
45-
import maestro.device.DeviceSpecRequest
4645
import maestro.utils.TemporaryDirectory
4746
import okio.BufferedSink
4847
import okio.buffer

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

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import maestro.cli.device.DeviceCreateUtil
77
import maestro.device.DeviceService
88
import maestro.cli.report.TestDebugReporter
99
import maestro.cli.util.EnvUtils
10+
import maestro.device.CPU_ARCHITECTURE
1011
import maestro.device.DeviceSpec
11-
import maestro.device.DeviceSpecRequest
1212
import maestro.device.Platform
13+
import maestro.device.locale.AndroidLocale
14+
import maestro.device.locale.IosLocale
15+
import maestro.device.locale.WebLocale
1316
import picocli.CommandLine
1417
import java.util.concurrent.Callable
1518

@@ -90,30 +93,39 @@ class StartDeviceCommand : Callable<Int> {
9093

9194
// Get the device configuration
9295
val parsedPlatform = Platform.fromString(platform)
93-
val maestroDeviceConfiguration = DeviceSpec.fromRequest(
94-
when (parsedPlatform) {
95-
Platform.ANDROID -> DeviceSpecRequest.Android(
96-
model = deviceModel,
97-
os = deviceOs ?: osVersion.let { "android-$it" },
98-
locale = deviceLocale,
96+
val deviceSpec: DeviceSpec = when (parsedPlatform) {
97+
Platform.ANDROID -> {
98+
val default = DeviceSpec.Android.DEFAULT
99+
DeviceSpec.Android(
100+
// osVersion is nullable; ?.let prevents interpolating "android-null"
101+
model = deviceModel ?: default.model,
102+
os = deviceOs ?: osVersion?.let { "android-$it" } ?: default.os,
103+
// AndroidLocale is a data class (no pre-defined constant); parse the default
104+
locale = deviceLocale?.let { AndroidLocale.fromString(it) } ?: default.locale,
99105
cpuArchitecture = EnvUtils.getMacOSArchitecture(),
100106
)
101-
Platform.IOS -> DeviceSpecRequest.Ios(
102-
model = deviceModel,
103-
os = deviceOs ?: osVersion.let { "iOS-$it" },
104-
locale = deviceLocale,
107+
}
108+
Platform.IOS -> {
109+
val default = DeviceSpec.Ios.DEFAULT
110+
DeviceSpec.Ios(
111+
model = deviceModel ?: default.model,
112+
os = deviceOs ?: osVersion?.let { "iOS-$it" } ?: default.os,
113+
locale = deviceLocale?.let { IosLocale.fromString(it) } ?: default.locale,
105114
)
106-
Platform.WEB -> DeviceSpecRequest.Web(
107-
model = deviceModel,
108-
os = deviceOs ?: osVersion,
109-
locale = deviceLocale,
115+
}
116+
Platform.WEB -> {
117+
val default = DeviceSpec.Web.DEFAULT
118+
DeviceSpec.Web(
119+
model = deviceModel ?: default.model,
120+
os = deviceOs ?: osVersion ?: default.os,
121+
locale = deviceLocale?.let { WebLocale.fromString(it) } ?: default.locale,
110122
)
111123
}
112-
)
124+
}
113125

114126
// Get/Create the device
115127
val device = DeviceCreateUtil.getOrCreateDevice(
116-
maestroDeviceConfiguration,
128+
deviceSpec,
117129
forceCreate
118130
)
119131

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

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package maestro.cli.device
33
import maestro.cli.CliError
44
import maestro.cli.util.PrintUtils
55
import maestro.device.Device
6-
import maestro.device.DeviceSpecRequest
76
import maestro.device.DeviceSpec
87
import maestro.device.Platform
98
import org.fusesource.jansi.Ansi.ansi
@@ -30,15 +29,11 @@ object PickDeviceView {
3029
Platform.fromString(it)
3130
} ?: throw CliError("Please specify a platform"))
3231

33-
val spec = DeviceSpec.fromRequest(
34-
when (selectedPlatform) {
35-
Platform.ANDROID -> DeviceSpecRequest.Android()
36-
Platform.IOS -> DeviceSpecRequest.Ios()
37-
Platform.WEB -> DeviceSpecRequest.Web()
38-
}
39-
)
40-
41-
return spec
32+
return when (selectedPlatform) {
33+
Platform.ANDROID -> DeviceSpec.Android.DEFAULT
34+
Platform.IOS -> DeviceSpec.Ios.DEFAULT
35+
Platform.WEB -> DeviceSpec.Web.DEFAULT
36+
}
4237
}
4338

4439
fun pickRunningDevice(devices: List<Device>): Device {

maestro-cli/src/main/java/maestro/cli/runner/resultview/AnsiResultView.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import maestro.device.Device
2424
import maestro.device.Platform
2525
import maestro.cli.runner.CommandState
2626
import maestro.cli.runner.CommandStatus
27-
import maestro.device.DeviceSpecRequest
2827
import maestro.device.DeviceSpec
2928
import maestro.orchestra.AssertWithAICommand
3029
import maestro.orchestra.ElementSelector
@@ -285,9 +284,7 @@ fun main() {
285284
flowName = "Flow for playing around",
286285
device = Device.Connected(
287286
instanceId = "device",
288-
deviceSpec = DeviceSpec.fromRequest(
289-
DeviceSpecRequest.Android()
290-
),
287+
deviceSpec = DeviceSpec.Android.DEFAULT,
291288
description = "description",
292289
platform = Platform.ANDROID,
293290
deviceType = Device.DeviceType.EMULATOR

maestro-client/src/main/java/maestro/device/DeviceService.kt

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ object DeviceService {
3838
Platform.IOS -> {
3939
PrintUtils.message("Launching Simulator...")
4040
try {
41+
val iosSpec = device.deviceSpec as DeviceSpec.Ios
4142
localSimulatorUtils.bootSimulator(device.modelId)
42-
PrintUtils.message("Setting the device locale to ${device.deviceSpec.locale.code}...")
43-
localSimulatorUtils.setDeviceLanguage(device.modelId, device.deviceSpec.locale.languageCode)
44-
localSimulatorUtils.setDeviceLocale(device.modelId, device.deviceSpec.locale.code)
43+
PrintUtils.message("Setting the device locale to ${iosSpec.locale.code}...")
44+
localSimulatorUtils.setDeviceLanguage(device.modelId, iosSpec.locale.languageCode)
45+
localSimulatorUtils.setDeviceLocale(device.modelId, iosSpec.locale.code)
4546
localSimulatorUtils.reboot(device.modelId)
4647
localSimulatorUtils.launchSimulator(device.modelId)
4748
localSimulatorUtils.awaitLaunch(device.modelId)
@@ -61,6 +62,7 @@ object DeviceService {
6162

6263
Platform.ANDROID -> {
6364
PrintUtils.message("Launching Emulator...")
65+
val androidSpec = device.deviceSpec as DeviceSpec.Android
6466
val emulatorBinary = requireEmulatorBinary()
6567

6668
ProcessBuilder(
@@ -92,19 +94,19 @@ object DeviceService {
9294
Thread.sleep(1000)
9395
}
9496

95-
PrintUtils.message("Setting the device locale to ${device.deviceSpec.locale.code}...")
97+
PrintUtils.message("Setting the device locale to ${androidSpec.locale.code}...")
9698
val driver = AndroidDriver(dadb, driverHostPort)
9799
driver.installMaestroDriverApp()
98100
val result = driver.setDeviceLocale(
99-
country = device.deviceSpec.locale.countryCode,
100-
language = device.deviceSpec.locale.languageCode,
101+
country = androidSpec.locale.countryCode,
102+
language = androidSpec.locale.languageCode,
101103
)
102104

103105
when (result) {
104-
SET_LOCALE_RESULT_SUCCESS -> PrintUtils.message("[Done] Setting the device locale to ${device.deviceSpec.locale.code}...")
105-
SET_LOCALE_RESULT_LOCALE_NOT_VALID -> throw IllegalStateException("Failed to set locale ${device.deviceSpec.locale.code}, the locale is not valid for a chosen device")
106-
SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED -> throw IllegalStateException("Failed to set locale ${device.deviceSpec.locale.code}, exception during updating configuration occurred")
107-
else -> throw IllegalStateException("Failed to set locale ${device.deviceSpec.locale.code}, unknown exception happened")
106+
SET_LOCALE_RESULT_SUCCESS -> PrintUtils.message("[Done] Setting the device locale to ${androidSpec.locale.code}...")
107+
SET_LOCALE_RESULT_LOCALE_NOT_VALID -> throw IllegalStateException("Failed to set locale ${androidSpec.locale.code}, the locale is not valid for a chosen device")
108+
SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED -> throw IllegalStateException("Failed to set locale ${androidSpec.locale.code}, exception during updating configuration occurred")
109+
else -> throw IllegalStateException("Failed to set locale ${androidSpec.locale.code}, unknown exception happened")
108110
}
109111
driver.uninstallMaestroDriverApp()
110112

@@ -166,14 +168,14 @@ object DeviceService {
166168
description = "Chromium Web Browser",
167169
instanceId = "chromium",
168170
deviceType = Device.DeviceType.BROWSER,
169-
deviceSpec = DeviceSpec.fromRequest(DeviceSpecRequest.Web())
171+
deviceSpec = DeviceSpec.Web.DEFAULT
170172
),
171173
Device.AvailableForLaunch(
172174
modelId = "chromium",
173175
description = "Chromium Web Browser",
174176
platform = Platform.WEB,
175177
deviceType = Device.DeviceType.BROWSER,
176-
deviceSpec = DeviceSpec.fromRequest(DeviceSpecRequest.Web())
178+
deviceSpec = DeviceSpec.Web.DEFAULT
177179
)
178180
)
179181
}
@@ -188,9 +190,7 @@ object DeviceService {
188190
description = dadb.toString(),
189191
platform = Platform.ANDROID,
190192
deviceType = Device.DeviceType.EMULATOR,
191-
deviceSpec = DeviceSpec.fromRequest(
192-
DeviceSpecRequest.Android()
193-
)
193+
deviceSpec = DeviceSpec.Android.DEFAULT
194194
)
195195
)
196196
}
@@ -221,15 +221,12 @@ object DeviceService {
221221
instanceId.startsWith("emulator") -> Device.DeviceType.EMULATOR
222222
else -> Device.DeviceType.REAL
223223
}
224-
val avdInfo = avdInfoList.find { it.name == avdName } ?: AvdInfo(name = avdName ?: "", model = "", os = "")
225224
Device.Connected(
226225
instanceId = instanceId,
227226
description = avdName ?: dadb.toString(),
228227
platform = Platform.ANDROID,
229228
deviceType = deviceType,
230-
deviceSpec = DeviceSpec.fromRequest(
231-
DeviceSpecRequest.Android()
232-
),
229+
deviceSpec = DeviceSpec.Android.DEFAULT,
233230
)
234231
}
235232
}.getOrNull() ?: emptyList()
@@ -251,9 +248,11 @@ object DeviceService {
251248
description = avdName,
252249
platform = Platform.ANDROID,
253250
deviceType = Device.DeviceType.EMULATOR,
254-
deviceSpec = DeviceSpec.fromRequest(
255-
DeviceSpecRequest.Android(avdInfo.model, avdInfo.os)
256-
)
251+
deviceSpec = if (avdInfo.model.isBlank() || avdInfo.os.isBlank()) {
252+
DeviceSpec.Android.DEFAULT
253+
} else {
254+
DeviceSpec.Android(model = avdInfo.model, os = avdInfo.os)
255+
}
257256
)
258257
}
259258
.toList()
@@ -369,9 +368,7 @@ object DeviceService {
369368
description = description,
370369
platform = Platform.IOS,
371370
deviceType = Device.DeviceType.REAL,
372-
deviceSpec = DeviceSpec.fromRequest(
373-
DeviceSpecRequest.Ios()
374-
)
371+
deviceSpec = DeviceSpec.Ios.DEFAULT
375372
)
376373
}
377374
}
@@ -395,19 +392,23 @@ object DeviceService {
395392
description = description,
396393
platform = Platform.IOS,
397394
deviceType = Device.DeviceType.SIMULATOR,
398-
deviceSpec = DeviceSpec.fromRequest(
399-
DeviceSpecRequest.Ios(model, os)
400-
)
395+
deviceSpec = if (model.isBlank() || os.isBlank()) {
396+
DeviceSpec.Ios.DEFAULT
397+
} else {
398+
DeviceSpec.Ios(model = model, os = os)
399+
}
401400
)
402401
} else {
403402
Device.AvailableForLaunch(
404403
modelId = device.udid,
405404
description = description,
406405
platform = Platform.IOS,
407406
deviceType = Device.DeviceType.SIMULATOR,
408-
deviceSpec = DeviceSpec.fromRequest(
409-
DeviceSpecRequest.Ios(model, os)
410-
)
407+
deviceSpec = if (model.isBlank() || os.isBlank()) {
408+
DeviceSpec.Ios.DEFAULT
409+
} else {
410+
DeviceSpec.Ios(model = model, os = os)
411+
}
411412
)
412413
}
413414
}
@@ -430,9 +431,7 @@ object DeviceService {
430431
description = output,
431432
platform = Platform.ANDROID,
432433
deviceType = Device.DeviceType.EMULATOR,
433-
deviceSpec = DeviceSpec.fromRequest(
434-
DeviceSpecRequest.Android()
435-
)
434+
deviceSpec = DeviceSpec.Android.DEFAULT
436435
)
437436
}
438437
.find { connectedDevice -> connectedDevice.description.contains(deviceName, ignoreCase = true) }

0 commit comments

Comments
 (0)