Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions maestro-cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ tasks.named("compileKotlin", KotlinCompilationTask::class.java) {
}
}

tasks.named<Test>("test") {
useJUnitPlatform()
}

tasks.create("createProperties") {
dependsOn("processResources")

Expand Down
3 changes: 3 additions & 0 deletions maestro-cli/src/main/java/maestro/cli/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ class App {
@Option(names = ["--port"], hidden = true)
var port: Int? = null

@Option(names = ["--driver-host-port"], hidden = true)
var driverHostPort: Int? = null

@Option(
names = ["--device", "--udid"],
description = ["(Optional) Device ID to run on explicitly, can be a comma separated list of IDs: --device \"Emulator_1,Emulator_2\" "],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ class PrintHierarchyCommand : Runnable {

@CommandLine.Option(
names = ["--reinstall-driver"],
description = ["Reinstalls driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. Set to false to skip reinstallation."],
description = ["Force reinstall of the driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. By default, reuses an existing healthy driver."],
negatable = true,
defaultValue = "true",
fallbackValue = "true"
defaultValue = "false",
fallbackValue = "true"
)
private var reinstallDriver: Boolean = true
private var reinstallDriver: Boolean = false

@Option(
names = ["--apple-team-id"],
Expand Down Expand Up @@ -108,7 +108,7 @@ class PrintHierarchyCommand : Runnable {
MaestroSessionManager.newSession(
host = parent?.host,
port = parent?.port,
driverHostPort = null,
driverHostPort = parent?.driverHostPort,
teamId = appleTeamId,
deviceId = parent?.deviceId,
platform = parent?.platform,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class QueryCommand : Runnable {
MaestroSessionManager.newSession(
host = parent?.host,
port = parent?.port,
driverHostPort = null,
driverHostPort = parent?.driverHostPort,
deviceId = parent?.deviceId,
platform = parent?.platform,
teamId = appleTeamId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ class RecordCommand : Callable<Int> {
return MaestroSessionManager.newSession(
host = parent?.host,
port = parent?.port,
driverHostPort = null,
driverHostPort = parent?.driverHostPort,
deviceId = deviceId,
teamId = appleTeamId,
platform = parent?.platform,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class StudioCommand : Callable<Int> {
MaestroSessionManager.newSession(
host = parent?.host,
port = parent?.port,
driverHostPort = null,
driverHostPort = parent?.driverHostPort,
teamId = appleTeamId,
deviceId = parent?.deviceId,
platform = parent?.platform,
Expand Down
27 changes: 19 additions & 8 deletions maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import maestro.cli.runner.resultview.AnsiResultView
import maestro.cli.runner.resultview.PlainTextResultView
import maestro.cli.session.MaestroSessionManager
import maestro.cli.util.CiUtils
import maestro.cli.util.isPortAvailable
import maestro.cli.util.EnvUtils
import maestro.cli.util.FileUtils.isWebFlow
import maestro.cli.util.PrintUtils
Expand Down Expand Up @@ -204,12 +205,12 @@ class TestCommand : Callable<Int> {

@Option(
names = ["--reinstall-driver"],
description = ["Reinstalls driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. Set to false to skip reinstallation."],
description = ["Force reinstall of the driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. By default, reuses an existing healthy driver."],
negatable = true,
defaultValue = "true",
defaultValue = "false",
fallbackValue = "true"
)
private var reinstallDriver: Boolean = true
private var reinstallDriver: Boolean = false

@Option(
names = ["--apple-team-id"],
Expand All @@ -226,7 +227,10 @@ class TestCommand : Callable<Int> {
description = ["Device ID to run on explicitly, can be a comma separated list of IDs: --device \"Emulator_1,Emulator_2\" "],
)
var deviceId: String? = null


@Option(names = ["--driver-host-port"], hidden = true)
var driverHostPort: Int? = null

@CommandLine.Spec
lateinit var commandSpec: CommandLine.Model.CommandSpec

Expand Down Expand Up @@ -532,11 +536,18 @@ class TestCommand : Callable<Int> {
}
}

private fun selectPort(effectiveShards: Int): Int =
if (effectiveShards == 1) 7001
else (7001..7128).shuffled().find { port ->
usedPorts.putIfAbsent(port, true) == null
private fun selectPort(effectiveShards: Int): Int {
val userPort = driverHostPort ?: parent?.driverHostPort
if (userPort != null) {
if (!isPortAvailable(userPort)) {
throw CliError("Requested driver host port $userPort is not available")
}
return userPort
}
return (7001..7128).shuffled().find { port ->
isPortAvailable(port) && usedPorts.putIfAbsent(port, true) == null
} ?: error("No available ports found")
}

private fun runSingleFlow(
maestro: Maestro,
Expand Down
39 changes: 31 additions & 8 deletions maestro-cli/src/main/java/maestro/cli/db/KeyValueStore.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package maestro.cli.db

import java.io.File
import java.io.RandomAccessFile
import java.nio.channels.FileLock
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
Expand All @@ -12,25 +14,31 @@ class KeyValueStore(private val dbFile: File) {
dbFile.createNewFile()
}

fun get(key: String): String? = lock.read { getCurrentDB()[key] }
fun get(key: String): String? = lock.read { withFileLock { getCurrentDB()[key] } }

fun set(key: String, value: String) = lock.write {
val db = getCurrentDB()
db[key] = value
commit(db)
withFileLock {
val db = getCurrentDB()
db[key] = value
commit(db)
}
}

fun delete(key: String) = lock.write {
val db = getCurrentDB()
db.remove(key)
commit(db)
withFileLock {
val db = getCurrentDB()
db.remove(key)
commit(db)
}
}

fun keys(): List<String> = lock.read { getCurrentDB().keys.toList() }
fun keys(): List<String> = lock.read { withFileLock { getCurrentDB().keys.toList() } }

private fun getCurrentDB(): MutableMap<String, String> {
if (dbFile.length() == 0L) return mutableMapOf()
return dbFile
.readLines()
.filter { it.contains("=") }
.associate { line ->
val (key, value) = line.split("=", limit = 2)
key to value
Expand All @@ -44,4 +52,19 @@ class KeyValueStore(private val dbFile: File) {
.joinToString("\n")
)
}

private fun <T> withFileLock(block: () -> T): T {
val raf = RandomAccessFile(dbFile, "rw")
return try {
val channel = raf.channel
val fileLock: FileLock = channel.lock()
try {
block()
} finally {
fileLock.release()
}
} finally {
raf.close()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ import kotlin.io.path.pathString

object MaestroSessionManager {
private const val defaultHost = "localhost"
private const val defaultXctestHost = "127.0.0.1"
// Use localhost (not 127.0.0.1) so name resolution can fall back to IPv6
// when the XCTest runner happens to bind to ::1 only. See #1299.
private const val defaultXctestHost = "localhost"
private const val defaultXcTestPort = 22087

private val executor = Executors.newScheduledThreadPool(1)
Expand All @@ -73,7 +75,7 @@ object MaestroSessionManager {
isStudio: Boolean = false,
isHeadless: Boolean = false,
screenSize: String? = null,
reinstallDriver: Boolean = true,
reinstallDriver: Boolean = false,
deviceIndex: Int? = null,
executionPlan: WorkspaceExecutionPlanner.ExecutionPlan? = null,
block: (MaestroSession) -> T,
Expand All @@ -88,12 +90,12 @@ object MaestroSessionManager {
deviceIndex = deviceIndex,
)
val sessionId = UUID.randomUUID().toString()
val effectiveDeviceId = selectedDevice.device?.instanceId ?: selectedDevice.deviceId

val heartbeatFuture = executor.scheduleAtFixedRate(
{
try {
Thread.sleep(1000) // Add a 1-second delay here for fixing race condition
SessionStore.heartbeat(sessionId, selectedDevice.platform)
SessionStore.default.heartbeat(sessionId, selectedDevice.platform, effectiveDeviceId)
} catch (e: Exception) {
logger.error("Failed to record heartbeat", e)
}
Expand All @@ -108,9 +110,10 @@ object MaestroSessionManager {
connectToExistingSession = if (isStudio) {
false
} else {
SessionStore.hasActiveSessions(
SessionStore.default.hasActiveSessionForDevice(
sessionId,
selectedDevice.platform
selectedDevice.platform,
effectiveDeviceId
)
},
isStudio = isStudio,
Expand All @@ -122,9 +125,9 @@ object MaestroSessionManager {
)
Runtime.getRuntime().addShutdownHook(thread(start = false) {
heartbeatFuture.cancel(true)
SessionStore.delete(sessionId, selectedDevice.platform)
SessionStore.default.delete(sessionId, selectedDevice.platform, effectiveDeviceId)
runCatching { ScreenReporter.reportMaxDepth() }
if (SessionStore.activeSessions().isEmpty()) {
if (SessionStore.default.shouldCloseSession(selectedDevice.platform, effectiveDeviceId)) {
session.close()
}
})
Expand Down
57 changes: 37 additions & 20 deletions maestro-cli/src/main/java/maestro/cli/session/SessionStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,12 @@ import maestro.device.Platform
import java.nio.file.Paths
import java.util.concurrent.TimeUnit

object SessionStore {
class SessionStore(private val keyValueStore: KeyValueStore) {

private val keyValueStore by lazy {
KeyValueStore(
dbFile = Paths
.get(System.getProperty("user.home"), ".maestro", "sessions")
.toFile()
.also { it.parentFile.mkdirs() }
)
}

fun heartbeat(sessionId: String, platform: Platform) {
fun heartbeat(sessionId: String, platform: Platform, deviceId: String? = null) {
synchronized(keyValueStore) {
keyValueStore.set(
key = key(sessionId, platform),
key = key(sessionId, platform, deviceId),
value = System.currentTimeMillis().toString(),
)

Expand All @@ -37,10 +28,10 @@ object SessionStore {
}
}

fun delete(sessionId: String, platform: Platform) {
fun delete(sessionId: String, platform: Platform, deviceId: String? = null) {
synchronized(keyValueStore) {
keyValueStore.delete(
key(sessionId, platform)
key(sessionId, platform, deviceId)
)
}
}
Expand All @@ -56,18 +47,44 @@ object SessionStore {
}
}

fun hasActiveSessions(
fun shouldCloseSession(platform: Platform, deviceId: String? = null): Boolean {
return activeSessionsForDevice(platform, deviceId).isEmpty()
}

fun activeSessionsForDevice(platform: Platform, deviceId: String? = null): List<String> {
val devicePrefix = "${platform}_${deviceId ?: "unknown"}_"
synchronized(keyValueStore) {
return activeSessions().filter { it.startsWith(devicePrefix) }
}
}

fun hasActiveSessionForDevice(
sessionId: String,
platform: Platform
platform: Platform,
deviceId: String? = null
): Boolean {
val currentKey = key(sessionId, platform, deviceId)
val devicePrefix = "${platform}_${deviceId ?: "unknown"}_"
synchronized(keyValueStore) {
return activeSessions()
.any { it != key(sessionId, platform) }
.any { it.startsWith(devicePrefix) && it != currentKey }
}
}

private fun key(sessionId: String, platform: Platform): String {
return "${platform}_$sessionId"
private fun key(sessionId: String, platform: Platform, deviceId: String? = null): String {
return "${platform}_${deviceId ?: "unknown"}_$sessionId"
}

}
companion object {
val default by lazy {
SessionStore(
KeyValueStore(
dbFile = Paths
.get(System.getProperty("user.home"), ".maestro", "sessions")
.toFile()
.also { it.parentFile.mkdirs() }
)
)
}
}
}
8 changes: 8 additions & 0 deletions maestro-cli/src/main/java/maestro/cli/util/SocketUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ package maestro.cli.util

import java.net.ServerSocket

fun isPortAvailable(port: Int): Boolean {
return try {
ServerSocket(port).use { true }
} catch (e: Exception) {
false
}
}

fun getFreePort(): Int {
(9999..11000).forEach { port ->
try {
Expand Down
Loading
Loading