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 b1f4eb463..173610643 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 @@ -170,6 +170,7 @@ sealed class VendorConfiguration { @JsonProperty("xcodebuildTestArgs") val xcodebuildTestArgs: Map = emptyMap(), @JsonProperty("dataContainerClear") val dataContainerClear: Boolean = false, @JsonProperty("testParserConfiguration") val testParserConfiguration: AppleTestParserConfiguration = AppleTestParserConfiguration.NmTestParserConfiguration(), + @JsonProperty("deviceLog") val deviceLog: Boolean = false, @JsonProperty("signing") val signing: SigningConfiguration = SigningConfiguration(), ) : VendorConfiguration() { diff --git a/core/src/main/kotlin/com/malinskiy/marathon/io/FileType.kt b/core/src/main/kotlin/com/malinskiy/marathon/io/FileType.kt index 68dea06a7..d6b019e35 100644 --- a/core/src/main/kotlin/com/malinskiy/marathon/io/FileType.kt +++ b/core/src/main/kotlin/com/malinskiy/marathon/io/FileType.kt @@ -5,6 +5,7 @@ enum class FileType(val dir: String, val suffix: String) { TEST("tests", "xml"), TEST_RESULT("test_result", "json"), LOG("logs", "log"), + DEVICE_LOG("device-logs", "log"), DEVICE_INFO("devices", "json"), VIDEO("video", "mp4"), SCREENSHOT("screenshot", "gif"), diff --git a/docs/runner/apple/configure/ios.md b/docs/runner/apple/configure/ios.md index 9c7f26e5c..4bdd0fc8b 100644 --- a/docs/runner/apple/configure/ios.md +++ b/docs/runner/apple/configure/ios.md @@ -609,6 +609,14 @@ testParserConfiguration: +### Capture device log + +By default, marathon does not pull device system logs. To investigate potential issues users might need to collect these. +System logs will be available at `$output-folder/device-files` per each batch (not per test!) due to latency issues + +```yaml +deviceLog: true +``` [1]: ../workers.md [2]: ../../configuration/dynamic-configuration.md diff --git a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt index 2b756f356..d04d92eb3 100644 --- a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt +++ b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/RemoteFileManager.kt @@ -99,6 +99,10 @@ class RemoteFileManager(private val device: AppleDevice) { return remoteFileForTest(screenshotFileName(udid, type)) } + fun remoteLog(): String { + return remoteFile("device.log") + } + private fun remoteFileForTest(filename: String): String { return "${outputDir}$FILE_SEPARATOR$filename" } diff --git a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/Simctl.kt b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/Simctl.kt index 825117aad..e8313ea4c 100644 --- a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/Simctl.kt +++ b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/Simctl.kt @@ -6,6 +6,7 @@ import com.malinskiy.marathon.apple.bin.xcrun.simctl.service.DeviceService import com.malinskiy.marathon.apple.bin.xcrun.simctl.service.IoService import com.malinskiy.marathon.apple.bin.xcrun.simctl.service.PrivacyService import com.malinskiy.marathon.apple.bin.xcrun.simctl.service.SimulatorService +import com.malinskiy.marathon.apple.bin.xcrun.simctl.service.SpawnService import com.malinskiy.marathon.apple.cmd.CommandExecutor import com.malinskiy.marathon.config.Configuration import com.malinskiy.marathon.config.vendor.VendorConfiguration @@ -23,4 +24,5 @@ class Simctl( val io = IoService(commandExecutor, timeoutConfiguration) val privacy = PrivacyService(commandExecutor, timeoutConfiguration) val application = ApplicationService(commandExecutor, timeoutConfiguration) + val spawn = SpawnService(commandExecutor, timeoutConfiguration) } diff --git a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/SimctlService.kt b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/SimctlService.kt index 9151f1021..1eeacb62f 100644 --- a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/SimctlService.kt +++ b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/SimctlService.kt @@ -2,6 +2,8 @@ package com.malinskiy.marathon.apple.bin.xcrun.simctl import com.malinskiy.marathon.apple.cmd.CommandExecutor import com.malinskiy.marathon.apple.cmd.CommandResult +import com.malinskiy.marathon.apple.cmd.CommandSession +import com.malinskiy.marathon.apple.extensions.Durations import com.malinskiy.marathon.config.vendor.apple.TimeoutConfiguration import java.time.Duration diff --git a/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/service/SpawnService.kt b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/service/SpawnService.kt new file mode 100644 index 000000000..0f3cfb2e7 --- /dev/null +++ b/vendor/vendor-apple/base/src/main/kotlin/com/malinskiy/marathon/apple/bin/xcrun/simctl/service/SpawnService.kt @@ -0,0 +1,23 @@ +package com.malinskiy.marathon.apple.bin.xcrun.simctl.service + +import com.malinskiy.marathon.apple.bin.xcrun.simctl.SimctlService +import com.malinskiy.marathon.apple.cmd.CommandExecutor +import com.malinskiy.marathon.apple.cmd.CommandResult +import com.malinskiy.marathon.apple.cmd.CommandSession +import com.malinskiy.marathon.config.vendor.apple.ios.Permission +import com.malinskiy.marathon.config.vendor.apple.TimeoutConfiguration +import java.time.Duration + +class SpawnService(commandExecutor: CommandExecutor, + private val timeoutConfiguration: TimeoutConfiguration, +) : SimctlService(commandExecutor) { + /** + * Spawn a process by executing a given executable on a device + */ + suspend fun spawn(udid: String, args: Array, timeout: Duration = timeoutConfiguration.shell): CommandResult { + return criticalExec( + timeout = timeout, + "spawn", udid, *args + ) + } +} diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt index 2712fa455..01762fce9 100644 --- a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/AppleSimulatorDevice.kt @@ -14,6 +14,7 @@ import com.malinskiy.marathon.apple.cmd.FileBridge import com.malinskiy.marathon.apple.configuration.Transport import com.malinskiy.marathon.apple.extensions.bundleConfiguration import com.malinskiy.marathon.apple.ios.listener.DataContainerClearListener +import com.malinskiy.marathon.apple.ios.listener.log.DeviceLogListener import com.malinskiy.marathon.apple.listener.AppleTestRunListener import com.malinskiy.marathon.apple.listener.CompositeTestRunListener import com.malinskiy.marathon.apple.listener.DebugTestRunListener @@ -354,6 +355,7 @@ class AppleSimulatorDevice( attachmentProviders ), logListener, + DeviceLogListener(this, vendorConfiguration.deviceLog, devicePoolId, testBatch), DebugTestRunListener(this), diagnosticLogsPathFinder, sessionResultsPathFinder, diff --git a/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/listener/log/DeviceLogListener.kt b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/listener/log/DeviceLogListener.kt new file mode 100644 index 000000000..f66c9791f --- /dev/null +++ b/vendor/vendor-apple/ios/src/main/kotlin/com/malinskiy/marathon/apple/ios/listener/log/DeviceLogListener.kt @@ -0,0 +1,79 @@ +package com.malinskiy.marathon.apple.ios.listener.log + +import com.malinskiy.marathon.apple.extensions.Durations +import com.malinskiy.marathon.apple.ios.AppleSimulatorDevice +import com.malinskiy.marathon.apple.listener.AppleTestRunListener +import com.malinskiy.marathon.apple.model.Sdk +import com.malinskiy.marathon.device.DevicePoolId +import com.malinskiy.marathon.device.toDeviceInfo +import com.malinskiy.marathon.io.FileType +import com.malinskiy.marathon.log.MarathonLogging +import com.malinskiy.marathon.test.TestBatch +import kotlin.system.measureTimeMillis + +class DeviceLogListener( + private val device: AppleSimulatorDevice, + private val enabled: Boolean, + private val pool: DevicePoolId, + private val testBatch: TestBatch, +) : AppleTestRunListener { + + private var pid: String? = null + private val logger = MarathonLogging.logger {} + private val supported by lazy { + when (device.sdk) { + Sdk.IPHONESIMULATOR, Sdk.TV_SIMULATOR, Sdk.WATCH_SIMULATOR, Sdk.VISION_SIMULATOR -> { + true + } + + else -> false + } + } + + override suspend fun beforeTestRun() { + super.beforeTestRun() + if (!enabled) return + + if (!supported) { + logger.warn { "Device ${device.serialNumber} does not support capturing device logs" } + return + } + + val remoteLogPath = device.remoteFileManager.remoteLog() + val result = + device.commandExecutor.criticalExecute( + Durations.INFINITE, + "sh", + "-c", + "xcrun simctl spawn ${device.udid} log stream --type log --color none --style compact 2>/dev/null > $remoteLogPath & echo $!" + ) + val possiblePid = result.combinedStdout.trim() + if (result.successful && possiblePid.toIntOrNull() != null) { + pid = result.combinedStdout.trim() + } + } + + override suspend fun afterTestRun() { + super.afterTestRun() + if (!enabled) return + if (!supported) { + logger.warn { "Device ${device.serialNumber} does not support capturing device logs" } + return + } + + if (pid != null) { + device.executeWorkerCommand(listOf("sh", "-c", "kill -s INT $pid")) + pullLogfile() + } + } + + private suspend fun pullLogfile() { + val localLogFile = + device.fileManager.createFile(FileType.DEVICE_LOG, pool, device.toDeviceInfo(), test = null, testBatchId = testBatch.id) + val remoteFilePath = device.remoteFileManager.remoteLog() + val millis = measureTimeMillis { + device.pullFile(remoteFilePath, localLogFile) + } + logger.debug { "Pulling finished in ${millis}ms $remoteFilePath " } + } +}