From db394872af537a83293e4181e2ae9632aa418d4b Mon Sep 17 00:00:00 2001 From: Anton Malinskiy Date: Thu, 10 Oct 2024 16:49:54 +1000 Subject: [PATCH] feat(ios): dynamic provider --- .../config/vendor/VendorConfiguration.kt | 3 + .../config/vendor/apple/DeviceProvider.kt | 26 +++ .../marathon/apple/configuration/Transport.kt | 7 + .../{MarathondevicesTest.kt => StaticTest.kt} | 8 +- vendor/vendor-apple/ios/build.gradle.kts | 7 + .../ios/device/AppleSimulatorProvider.kt | 177 ++++++++++++------ .../apple/ios/device/DeviceTracker.kt | 138 ++++++++++++++ .../apple/ios/device/ProvisioningPlan.kt | 8 +- .../marathon/apple/ios/model/Udid.kt | 3 + .../apple/ios/device/DeviceTrackerTest.kt | 131 +++++++++++++ .../fixtures/marathondevices/all.yaml | 20 ++ .../fixtures/marathondevices/allx2.yaml | 28 +++ .../fixtures/marathondevices/empty.yaml | 11 ++ .../fixtures/marathondevices/host.yaml | 12 ++ .../fixtures/marathondevices/simulator.yaml | 13 ++ .../marathondevices/simulatorprofile.yaml | 15 ++ .../marathondevices/simulatorprofilex2.yaml | 19 ++ .../marathondevices/simulatorprofilex3.yaml | 23 +++ .../marathondevices/simulatorprofilex4.yaml | 27 +++ .../fixtures/marathondevices/udid.yaml | 13 ++ 20 files changed, 628 insertions(+), 61 deletions(-) create mode 100644 configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/DeviceProvider.kt rename vendor/vendor-apple/base/src/test/kotlin/com/malinskiy/marathon/apple/configuration/{MarathondevicesTest.kt => StaticTest.kt} (86%) create mode 100644 vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTracker.kt create mode 100644 vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/model/Udid.kt create mode 100644 vendor/vendor-apple/ios/src/test/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTrackerTest.kt create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/all.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/allx2.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/empty.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/host.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulator.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofile.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex2.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex3.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex4.yaml create mode 100644 vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/udid.yaml diff --git a/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/VendorConfiguration.kt b/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/VendorConfiguration.kt index 9c2aa68b1..e9bcd2579 100644 --- a/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/VendorConfiguration.kt +++ b/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/VendorConfiguration.kt @@ -14,6 +14,7 @@ import com.malinskiy.marathon.config.vendor.android.TestAccessConfiguration import com.malinskiy.marathon.config.vendor.android.TestParserConfiguration import com.malinskiy.marathon.config.vendor.android.ThreadingConfiguration import com.malinskiy.marathon.config.vendor.apple.AppleTestBundleConfiguration +import com.malinskiy.marathon.config.vendor.apple.DeviceProvider import com.malinskiy.marathon.config.vendor.apple.ios.LifecycleConfiguration import com.malinskiy.marathon.config.vendor.apple.ios.PermissionsConfiguration import com.malinskiy.marathon.config.vendor.apple.RsyncConfiguration @@ -155,6 +156,7 @@ sealed class VendorConfiguration { */ data class IOSConfiguration( @JsonProperty("bundle") val bundle: AppleTestBundleConfiguration? = null, + @JsonProperty("deviceProvider") val deviceProvider: DeviceProvider = DeviceProvider.Static(), @JsonProperty("devices") val devicesFile: File? = null, @JsonProperty("ssh") val ssh: SshConfiguration = SshConfiguration(), @@ -186,6 +188,7 @@ sealed class VendorConfiguration { data class MacosConfiguration( @JsonProperty("bundle") val bundle: AppleTestBundleConfiguration? = null, + @JsonProperty("deviceProvider") val deviceProvider: DeviceProvider = DeviceProvider.Static(), @JsonProperty("devices") val devicesFile: File? = null, @JsonProperty("ssh") val ssh: SshConfiguration = SshConfiguration(), diff --git a/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/DeviceProvider.kt b/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/DeviceProvider.kt new file mode 100644 index 000000000..2f39b4855 --- /dev/null +++ b/configuration/src/main/kotlin/com/malinskiy/marathon/config/vendor/apple/DeviceProvider.kt @@ -0,0 +1,26 @@ +package com.malinskiy.marathon.config.vendor.apple + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import java.io.File + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes( + JsonSubTypes.Type(value = DeviceProvider.Static::class, names = arrayOf("static", "marathondevices")), + JsonSubTypes.Type(value = DeviceProvider.Dynamic::class, name = "dynamic"), +) +sealed class DeviceProvider { + data class Static( + @JsonProperty("path") val path: File? = null + ) : DeviceProvider() + + data class Dynamic( + @JsonProperty("host") val host: String = "127.0.0.1", + @JsonProperty("port") val port: Int = 5037, + ) +} diff --git a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/configuration/Transport.kt b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/configuration/Transport.kt index 8dd421571..c139c1526 100644 --- a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/configuration/Transport.kt +++ b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/configuration/Transport.kt @@ -23,4 +23,11 @@ sealed interface Transport { @JsonProperty("authentication") val authentication: SshAuthentication? = null, @JsonProperty("checkReachability") val checkReachability: Boolean = true, ) : Transport + + fun id(): String { + return when (this) { + Local -> "local" + is Ssh -> "${addr}:${port}" + } + } } diff --git a/vendor/vendor-apple/base/src/test/kotlin/com/malinskiy/marathon/apple/configuration/MarathondevicesTest.kt b/vendor/vendor-apple/base/src/test/kotlin/com/malinskiy/marathon/apple/configuration/StaticTest.kt similarity index 86% rename from vendor/vendor-apple/base/src/test/kotlin/com/malinskiy/marathon/apple/configuration/MarathondevicesTest.kt rename to vendor/vendor-apple/base/src/test/kotlin/com/malinskiy/marathon/apple/configuration/StaticTest.kt index f699a7e94..170f97b43 100644 --- a/vendor/vendor-apple/base/src/test/kotlin/com/malinskiy/marathon/apple/configuration/MarathondevicesTest.kt +++ b/vendor/vendor-apple/base/src/test/kotlin/com/malinskiy/marathon/apple/configuration/StaticTest.kt @@ -6,17 +6,13 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator import com.fasterxml.jackson.module.kotlin.KotlinFeature import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.readValue -import com.malinskiy.marathon.apple.configuration.AppleTarget -import com.malinskiy.marathon.apple.configuration.Marathondevices -import com.malinskiy.marathon.apple.configuration.Transport -import com.malinskiy.marathon.apple.configuration.Worker import com.malinskiy.marathon.config.vendor.apple.SshAuthentication import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.File -class MarathondevicesTest { +class StaticTest { lateinit var mapper: ObjectMapper @BeforeEach @@ -36,7 +32,7 @@ class MarathondevicesTest { @Test fun testSample1() { - val src = File(MarathondevicesTest::class.java.getResource("/fixtures/marathondevices/sample_1.yaml").file) + val src = File(StaticTest::class.java.getResource("/fixtures/marathondevices/sample_1.yaml").file) val actual = mapper.readValue(src) actual shouldBeEqualTo Marathondevices( diff --git a/vendor/vendor-apple/ios/build.gradle.kts b/vendor/vendor-apple/ios/build.gradle.kts index 5aee92714..85ecf4622 100644 --- a/vendor/vendor-apple/ios/build.gradle.kts +++ b/vendor/vendor-apple/ios/build.gradle.kts @@ -7,6 +7,13 @@ plugins { dependencies { implementation(project(":vendor:vendor-apple:base")) + + testImplementation(TestLibraries.kluent) + testImplementation(TestLibraries.assertk) + testImplementation(TestLibraries.mockitoKotlin) + testImplementation(TestLibraries.junit5) + testImplementation(TestLibraries.coroutinesTest) + testRuntimeOnly(TestLibraries.jupiterEngine) } setupDeployment() diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/AppleSimulatorProvider.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/AppleSimulatorProvider.kt index 3850c5262..8a7815d2e 100644 --- a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/AppleSimulatorProvider.kt +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/AppleSimulatorProvider.kt @@ -17,10 +17,10 @@ import com.malinskiy.marathon.apple.cmd.FileBridge import com.malinskiy.marathon.apple.configuration.AppleTarget import com.malinskiy.marathon.apple.configuration.Marathondevices import com.malinskiy.marathon.apple.configuration.Transport -import com.malinskiy.marathon.apple.configuration.Worker import com.malinskiy.marathon.apple.device.ConnectionFactory import com.malinskiy.marathon.config.Configuration import com.malinskiy.marathon.config.vendor.VendorConfiguration +import com.malinskiy.marathon.config.vendor.apple.DeviceProvider.Static import com.malinskiy.marathon.device.Device import com.malinskiy.marathon.device.DeviceProvider import com.malinskiy.marathon.exceptions.NoDevicesException @@ -33,13 +33,17 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.onSuccess +import kotlinx.coroutines.channels.produce import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.newFixedThreadPoolContext import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield import org.apache.commons.text.StringSubstitutor import org.apache.commons.text.lookup.StringLookupFactory import java.io.File @@ -47,6 +51,9 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.CoroutineContext +//Should not use udid as key to support multiple devices with the same udid across transports +typealias AppleDeviceId = String + class AppleSimulatorProvider( private val configuration: Configuration, private val vendorConfiguration: VendorConfiguration.IOSConfiguration, @@ -66,7 +73,7 @@ class AppleSimulatorProvider( private var monitoringJob: Job? = null - private val devices = ConcurrentHashMap() + private val devices = ConcurrentHashMap() private val channel: Channel = unboundedChannel() private val connectionFactory = ConnectionFactory( configuration, @@ -76,58 +83,125 @@ class AppleSimulatorProvider( ) private val environmentVariableSubstitutor = StringSubstitutor(StringLookupFactory.INSTANCE.environmentVariableStringLookup()) private val simulatorFactory = SimulatorFactory(configuration, vendorConfiguration, testBundleIdentifier, gson, track, timer) + private val deviceTracker = DeviceTracker() override fun subscribe() = channel + private val sourceMutex = Mutex() + private lateinit var sourceChannel: ReceiveChannel + override suspend fun initialize() { logger.debug("Initializing AppleSimulatorProvider") + + //Fail fast if we use static provider with no devices available val file = vendorConfiguration.devicesFile ?: File(System.getProperty("user.dir"), "Marathondevices") - val devicesWithEnvironmentVariablesReplaced = environmentVariableSubstitutor.replace(file.readText()) - val workers: List = try { - objectMapper.readValue(devicesWithEnvironmentVariablesReplaced).workers - } catch (e: JsonMappingException) { - throw NoDevicesException("Invalid Marathondevices file ${file.absolutePath} format", e) - } - if (workers.isEmpty()) { - throw NoDevicesException("No workers found in the ${file.absolutePath}") - } - val hosts: Map> = mutableMapOf>().apply { - workers.map { - put(it.transport, it.devices) + var initialMarathonfile: Marathondevices? = null + if (vendorConfiguration.deviceProvider is Static || file.exists()) { + val devicesWithEnvironmentVariablesReplaced = environmentVariableSubstitutor.replace(file.readText()) + val marathonfile = try { + objectMapper.readValue(devicesWithEnvironmentVariablesReplaced) + } catch (e: JsonMappingException) { + throw NoDevicesException("Invalid Marathondevices file ${file.absolutePath} format", e) + } + if (marathonfile.workers.isEmpty()) { + throw NoDevicesException("No workers found in the ${file.absolutePath}") } + initialMarathonfile = marathonfile } - logger.debug { "Establishing communication with [${hosts.keys.joinToString()}]" } - val deferred = hosts.map { (transport, targets) -> - async { - initializeForTransport(targets, transport) - } + monitoringJob = if (initialMarathonfile != null) { + startStaticProvider(initialMarathonfile) + } else { + startDynamicProvider() } - awaitAll(*deferred.toTypedArray()) + } - monitoringJob = launch { + private fun startDynamicProvider(): Job { + return launch { while (isActive) { - var recreate = mutableSetOf() - devices.values.forEach { device -> - if (!device.commandExecutor.connected) { - channel.send(DeviceProvider.DeviceEvent.DeviceDisconnected(device)) - device.dispose() - recreate.add(device) + sourceMutex.withLock { + sourceChannel = produce { + //TODO: dynamic provider } } - val byTransport = recreate.groupBy { it.transport } - byTransport.forEach { (transport, devices) -> - val plan = ProvisioningPlan(existingSimulators = devices.map { - logger.warn { "Re-provisioning ${it.serialNumber}" } - it.udid - }.toSet(), emptyList(), emptySet()) - createExisting(plan, transport) + + while(true) { + val channelResult = sourceChannel.tryReceive() + channelResult.onSuccess { processUpdate(it) } + reconnect() + delay(16) } - recreate.clear() + } + } + } + + private fun startStaticProvider(marathondevices: Marathondevices): Job { + return launch { + processUpdate(marathondevices) + while (isActive) { + reconnect() delay(16) } } - Unit + } + + private suspend fun reconnect() { + var recreate = mutableSetOf() + devices.values.forEach { device -> + if (!device.commandExecutor.connected) { + channel.send(DeviceProvider.DeviceEvent.DeviceDisconnected(device)) + device.dispose() + recreate.add(device) + } + } + val byTransport = recreate.groupBy { it.transport } + byTransport.forEach { (transport, devices) -> + val plan = ProvisioningPlan(existingSimulators = devices.map { + logger.warn { "Re-provisioning ${it.serialNumber}" } + it.udid + }.toSet(), emptyList(), emptySet()) + createExisting(plan, transport) + } + recreate.clear() + } + + private suspend fun processUpdate(initialMarathonfile: Marathondevices) { + val update: Map> = deviceTracker.update(initialMarathonfile) + val deferred = update.mapNotNull { (transport, updates) -> + logger.debug { "Processing updates from $transport:\n${updates.joinToString(separator = "\n", prefix = "- ")}" } + + val connected = updates.filterIsInstance() + val disconnected = updates.filterIsInstance() + + disconnected.mapNotNull { it -> + val appleId = when (it.target) { + is AppleTarget.Physical -> toAppleId(it.target.udid, transport) + is AppleTarget.Simulator -> toAppleId(it.target.udid, transport) + AppleTarget.Host -> { + logger.warn { "host devices are not support by apple simulator provider" } + null + } + + is AppleTarget.SimulatorProfile -> { + logger.warn { "simulator profile devices do not support disconnect" } + null + } + } + appleId?.let { devices[it] } + }.forEach { + notifyDisconnected(it) + dispose(it) + } + + if (connected.isNotEmpty()) { + //If we already connected to this host then reuse existing + async { + initializeForTransport(connected.map { it.target }, transport) + } + } else null + } + + awaitAll(*deferred.toTypedArray()) } override suspend fun borrow(): Device { @@ -160,7 +234,8 @@ class AppleSimulatorProvider( return@async } val simulator = createSimulator(udid, transport, commandExecutor, fileBridge) - connect(transport, simulator) + val id: AppleDeviceId = toAppleId(udid, transport) + connect(id, simulator) } } } @@ -201,7 +276,8 @@ class AppleSimulatorProvider( return@async } val simulator = createSimulator(udid, transport, commandExecutor, fileBridge) - connect(transport, simulator) + val id: AppleDeviceId = toAppleId(udid, transport) + connect(id, simulator) } else { logger.error { "Failed to create simulator for profile $profile" } } @@ -210,6 +286,8 @@ class AppleSimulatorProvider( } } + private fun toAppleId(udid: String?, transport: Transport) = "$udid@${transport.id()}" + private fun verifySimulatorCanBeProvisioned( simctlListDevicesOutput: SimctlListDevicesOutput, profile: AppleTarget.SimulatorProfile, @@ -251,7 +329,10 @@ class AppleSimulatorProvider( } } val availableUdids = simulatorDevices.keys - val usedUdids = mutableSetOf() + val usedUdids = devices.values + .filter { it.transport == transport } + .map { it.udid } + .toMutableSet() simulators.forEach { if (!availableUdids.contains(it.udid)) { logger.error { "udid ${it.udid} is not available at $transport" } @@ -259,6 +340,7 @@ class AppleSimulatorProvider( usedUdids.add(it.udid) } } + val unusedDevices = simulatorDevices.filterKeys { !usedUdids.contains(it) }.toMutableMap() val reuseUdid = mutableSetOf() val createProfiles = mutableListOf() @@ -352,9 +434,8 @@ class AppleSimulatorProvider( device.dispose() } - private fun connect(transport: Transport, device: AppleSimulatorDevice) { - //Should not use udid as key here to support multiple devices with the same udid across transports - devices.put(device.serialNumber, device) + private fun connect(id: AppleDeviceId, device: AppleSimulatorDevice) { + devices.put(id, device) ?.let { logger.error("replaced existing device $it with new $device.") dispose(it) @@ -369,14 +450,4 @@ class AppleSimulatorProvider( private fun notifyDisconnected(device: AppleSimulatorDevice) = launch(context = coroutineContext) { channel.send(element = DeviceProvider.DeviceEvent.DeviceDisconnected(device)) } - -// private fun printFailingSimulatorSummary() { -// simulators -// .map { "${it.udid}@${it.transport}" to (RemoteSimulatorConnectionCounter.get(it.udid) - 1) } -// .filter { it.second > 0 } -// .sortedByDescending { it.second } -// .forEach { -// logger.debug(String.format("%3d %s", it.second, it.first)) -// } -// } } diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTracker.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTracker.kt new file mode 100644 index 000000000..51a6c172a --- /dev/null +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTracker.kt @@ -0,0 +1,138 @@ +package com.malinskiy.marathon.apple.ios.device + +import com.malinskiy.marathon.apple.configuration.AppleTarget +import com.malinskiy.marathon.apple.configuration.Marathondevices +import com.malinskiy.marathon.apple.configuration.Transport +import com.malinskiy.marathon.apple.configuration.Worker +import com.malinskiy.marathon.log.MarathonLogging +import java.util.concurrent.ConcurrentHashMap + +class DeviceTracker { + private val workers: ConcurrentHashMap = ConcurrentHashMap() + + fun update(marathondevices: Marathondevices): Map> { + val updateWorkers = marathondevices.workers.map { it.id() }.toSet() + + val result = hashMapOf>() + + val workersNotReportedAnymore = workers.filterKeys { !updateWorkers.contains(it) } + workersNotReportedAnymore.forEach { (id, tracker) -> + val update = tracker.update(emptyList()) + result[tracker.transport] = update.map { it.second } + } + + marathondevices.workers.forEach { worker -> + val id = worker.id() + val tracker = workers.getOrPut(id) { WorkerTracker(worker.transport) } + val update = tracker.update(worker.devices).map { Pair(worker.transport, it.second) } + result[tracker.transport] = update.map { it.second } + } + + return result + } + + private fun Worker.id() = transport.id() +} + +/** + * Tracks only expands to provisioned devices + */ +class WorkerTracker(val transport: Transport) { + private val devices: ConcurrentHashMap = ConcurrentHashMap() + private val logger = MarathonLogging.logger { } + + private fun AppleTarget.id(): String { + return when (this) { + AppleTarget.Host -> "host" + is AppleTarget.Physical -> udid + is AppleTarget.Simulator -> udid + is AppleTarget.SimulatorProfile -> "${fullyQualifiedDeviceTypeId}-${fullyQualifiedRuntimeId ?: ""}" + } + } + + fun update(updatedDevices: List): List> { + val updates = mutableListOf>() + val (simulatorProfiles, stableDevices) = updatedDevices.partition { it is AppleTarget.SimulatorProfile } + val simulatorProfilesDesiredCount = (simulatorProfiles as List).groupBy { it.id() } + val updateIds = simulatorProfilesDesiredCount.keys + stableDevices.map { it.id() } + + val devicesNotReportedAnymore = devices.filter { !updateIds.contains(it.key) } + devicesNotReportedAnymore.forEach { (id, state) -> + when (state) { + is DeviceState.ONLINE -> { + updates.add(Pair(state.target, TrackingUpdate.Disconnected(state.target))) + devices.remove(id) + } + + is DeviceState.PROVISIONED -> { + val currentCount = state.count + logger.warn { "Deprovisioning simulators is not supported. Current = $currentCount, desired = 0" } + } + + is DeviceState.OFFLINE -> Unit + } + } + + //Devices that don't require provisioning + stableDevices.forEach { + val previousState = devices[it.id()] + + if (previousState == null) { + devices[it.id()] = DeviceState.ONLINE(it) + updates.add(Pair(it, TrackingUpdate.Connected(it))) + } else { + when { + previousState !is DeviceState.ONLINE -> { + devices[it.id()] = DeviceState.ONLINE(it) + updates.add(Pair(it, TrackingUpdate.Connected(it))) + } + else -> Unit + } + } + } + + //Devices that require provisioning can repeat. These don't have a stable identifier like UDID + simulatorProfilesDesiredCount.forEach { (id, desired) -> + val previousState = devices[id] + + val target = desired.first() + if (previousState == null) { + devices[id] = DeviceState.PROVISIONED(target, desired.size.toUInt()) + updates.add(Pair(target, TrackingUpdate.Connected(target))) + } else { + val desiredCount = desired.size.toUInt() + val currentCount = (previousState as DeviceState.PROVISIONED).count + when { + desiredCount > currentCount -> { + devices[id] = DeviceState.PROVISIONED(target, desired.size.toUInt()) + repeat((desiredCount - currentCount).toInt()) { + updates.add(Pair(target, TrackingUpdate.Connected(target))) + } + } + desiredCount == currentCount -> Unit + desiredCount < currentCount -> { + logger.warn { "Deprovisioning simulators is not supported. Current = $currentCount, desired = $desiredCount" } + } + } + } + } + + return updates + } +} + +sealed class TrackingUpdate { + data class Connected(val target: AppleTarget) : TrackingUpdate() + data class Disconnected(val target: AppleTarget) : TrackingUpdate() +} + +sealed class DeviceState(open val target: AppleTarget) { + data class ONLINE(override val target: AppleTarget) : DeviceState(target) + data class PROVISIONED(override val target: AppleTarget.SimulatorProfile, val count: UInt) : DeviceState(target) + data class OFFLINE(override val target: AppleTarget) : DeviceState(target) +} + +data class Device( + val transport: Transport, + val target: AppleTarget, +) diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/ProvisioningPlan.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/ProvisioningPlan.kt index 49a1ad9e9..0c172e607 100644 --- a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/ProvisioningPlan.kt +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/device/ProvisioningPlan.kt @@ -1,9 +1,13 @@ package com.malinskiy.marathon.apple.ios.device import com.malinskiy.marathon.apple.configuration.AppleTarget +import com.malinskiy.marathon.apple.ios.model.UDID +/** + * Also works as a de-provisioning plan + */ data class ProvisioningPlan( - val existingSimulators: Set, + val existingSimulators: Set, val needsProvisioning: List, - val physical: Set + val physical: Set ) diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/model/Udid.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/model/Udid.kt new file mode 100644 index 000000000..9117697c3 --- /dev/null +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/model/Udid.kt @@ -0,0 +1,3 @@ +package com.malinskiy.marathon.apple.ios.model + +typealias UDID = String diff --git a/vendor/vendor-apple/ios/src/test/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTrackerTest.kt b/vendor/vendor-apple/ios/src/test/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTrackerTest.kt new file mode 100644 index 000000000..4d445c4ab --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/kotlin/com/malinskiy/marathon/apple/ios/device/DeviceTrackerTest.kt @@ -0,0 +1,131 @@ +package com.malinskiy.marathon.apple.ios.device + +import assertk.assertThat +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEmpty +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import com.fasterxml.jackson.module.kotlin.KotlinFeature +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import com.malinskiy.marathon.apple.configuration.AppleTarget +import com.malinskiy.marathon.apple.configuration.Marathondevices +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File + +class DeviceTrackerTest { + @Test + fun testWorkerSimple() { + val workerTracker = WorkerTracker(worker.transport) + val marathondevices = readFixture("all.yaml") + val worker = marathondevices.workers.first() + val update: List> = workerTracker.update(worker.transport, worker.devices) + + val simulator = AppleTarget.Simulator("12345-ABCDE-54321") + val simulatorProfile = AppleTarget.SimulatorProfile( + deviceTypeId = "iPhone X", runtimeId = "com.apple.CoreSimulator.SimRuntime.iOS-14-5", newNamePrefix = "testSim" + ) + val physical = AppleTarget.Physical("98765-ZYXWV-56789") + val actual = update.map { it.second }.toList() + + + assertThat(actual).containsExactlyInAnyOrder( + TrackingUpdate.Connected(AppleTarget.Host), + TrackingUpdate.Connected(simulator), + TrackingUpdate.Connected(simulatorProfile), + TrackingUpdate.Connected(physical), + ) + + //Same state + val noopUpdate: List> = workerTracker.update(worker.transport, worker.devices) + val actualNoop = noopUpdate.map { it.second }.toList() + assertThat(actualNoop).isEmpty() + + //All x2 + val marathondevicesX2 = readFixture("allx2.yaml") + val workerX2 = marathondevicesX2.workers.first() + val simulatorx2 = AppleTarget.Simulator("12345-ABCDE-54322") + val simulatorProfilex2 = AppleTarget.SimulatorProfile( + deviceTypeId = "iPhone X", runtimeId = "com.apple.CoreSimulator.SimRuntime.iOS-14-5", newNamePrefix = "testSim" + ) + val physicalx2 = AppleTarget.Physical("98765-ZYXWV-56780") + val x2Update: List> = workerTracker.update(workerX2.transport, workerX2.devices) + val actualX2 = x2Update.map { it.second }.toList() + + assertThat(actualX2).containsExactlyInAnyOrder( + TrackingUpdate.Connected(simulatorx2), + TrackingUpdate.Connected(simulatorProfilex2), + TrackingUpdate.Connected(physicalx2), + ) + } + + @Test + fun testSimulatorProfileScaling() { + val workerTracker = WorkerTracker(worker.transport) + val marathondevices = readFixture("simulatorprofile.yaml") + val worker = marathondevices.workers.first() + val update: List> = workerTracker.update(worker.transport, worker.devices) + val actual1 = update.map { it.second }.toList() + + val simulatorProfile = AppleTarget.SimulatorProfile( + deviceTypeId = "iPhone X", runtimeId = "com.apple.CoreSimulator.SimRuntime.iOS-14-5", newNamePrefix = "testSim" + ) + + assertThat(actual1).containsExactlyInAnyOrder( + TrackingUpdate.Connected(simulatorProfile), + ) + + //Scale up x3 + val marathondevicesX3 = readFixture("simulatorprofilex3.yaml") + val workerX3 = marathondevicesX3.workers.first() + val x3Update: List> = workerTracker.update(workerX3.transport, workerX3.devices) + val actualX3 = x3Update.map { it.second }.toList() + + assertThat(actualX3).containsExactlyInAnyOrder( + TrackingUpdate.Connected(simulatorProfile), + TrackingUpdate.Connected(simulatorProfile), + ) + + //Scale down to x2 + val marathondevicesX2 = readFixture("simulatorprofilex2.yaml") + val workerX2 = marathondevicesX2.workers.first() + val x2Update: List> = workerTracker.update(workerX2.transport, workerX2.devices) + val actualX2 = x2Update.map { it.second }.toList() + + assertThat(actualX2).isEmpty() + + //Scale up to x4 + val marathondevicesX4 = readFixture("simulatorprofilex4.yaml") + val workerX4 = marathondevicesX4.workers.first() + val x4Update: List> = workerTracker.update(workerX4.transport, workerX4.devices) + val actualX4 = x4Update.map { it.second }.toList() + + assertThat(actualX4).containsExactlyInAnyOrder( + TrackingUpdate.Connected(simulatorProfile), + ) + } + + fun readFixture(path: String): Marathondevices { + val src = File(DeviceTrackerTest::class.java.getResource("/fixtures/marathondevices/$path").file) + return mapper.readValue(src) + } + + lateinit var mapper: ObjectMapper + + @BeforeEach + fun `setup yaml mapper`() { + mapper = ObjectMapper(YAMLFactory().disable(YAMLGenerator.Feature.USE_NATIVE_TYPE_ID)) + mapper.registerModule( + KotlinModule.Builder() + .withReflectionCacheSize(512) + .configure(KotlinFeature.NullToEmptyCollection, false) + .configure(KotlinFeature.NullToEmptyMap, false) + .configure(KotlinFeature.NullIsSameAsDefault, false) + .configure(KotlinFeature.SingletonSupport, true) + .configure(KotlinFeature.StrictNullChecks, false) + .build() + ) + } +} diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/all.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/all.yaml new file mode 100644 index 000000000..95130e1b1 --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/all.yaml @@ -0,0 +1,20 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: simulator + udid: 12345-ABCDE-54321 + - type: physical + udid: 98765-ZYXWV-56789 + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: host diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/allx2.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/allx2.yaml new file mode 100644 index 000000000..40fa6f256 --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/allx2.yaml @@ -0,0 +1,28 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: simulator + udid: 12345-ABCDE-54321 + - type: physical + udid: 98765-ZYXWV-56789 + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: host + - type: simulator + udid: 12345-ABCDE-54322 + - type: physical + udid: 98765-ZYXWV-56780 + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/empty.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/empty.yaml new file mode 100644 index 000000000..cbb175055 --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/empty.yaml @@ -0,0 +1,11 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/host.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/host.yaml new file mode 100644 index 000000000..f98411aee --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/host.yaml @@ -0,0 +1,12 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: host diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulator.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulator.yaml new file mode 100644 index 000000000..6ce7a5192 --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulator.yaml @@ -0,0 +1,13 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: simulator + udid: 12345-ABCDE-54321 diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofile.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofile.yaml new file mode 100644 index 000000000..460ed5caa --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofile.yaml @@ -0,0 +1,15 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex2.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex2.yaml new file mode 100644 index 000000000..60316097d --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex2.yaml @@ -0,0 +1,19 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex3.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex3.yaml new file mode 100644 index 000000000..594704103 --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex3.yaml @@ -0,0 +1,23 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex4.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex4.yaml new file mode 100644 index 000000000..98d4dedf5 --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/simulatorprofilex4.yaml @@ -0,0 +1,27 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim + - type: simulatorProfile + deviceType: iPhone X + runtime: com.apple.CoreSimulator.SimRuntime.iOS-14-5 + newNamePrefix: testSim diff --git a/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/udid.yaml b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/udid.yaml new file mode 100644 index 000000000..2dc6b1214 --- /dev/null +++ b/vendor/vendor-apple/ios/src/test/resources/fixtures/marathondevices/udid.yaml @@ -0,0 +1,13 @@ +workers: + - transport: + type: ssh + addr: 192.168.1.10 + port: 22 + authentication: + type: publicKey + username: user123 + key: "/path/to/private/key" + checkReachability: true + devices: + - type: physical + udid: 98765-ZYXWV-56789