Skip to content

Commit

Permalink
feat(android): chunked video recording for API <34 and infinite recor…
Browse files Browse the repository at this point in the history
…ding for API >=34 (#962)

---------

Co-authored-by: SergKhram <[email protected]>
Co-authored-by: Sergei Khramkov <[email protected]>
Co-authored-by: Konstantin Aksenov <[email protected]>
  • Loading branch information
4 people authored Jul 30, 2024
1 parent 00a99b2 commit b5ea9c6
Show file tree
Hide file tree
Showing 22 changed files with 376 additions and 147 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@ package com.malinskiy.marathon.execution

import java.io.File

data class Attachment(val file: File, val type: AttachmentType)
data class Attachment(val file: File, val type: AttachmentType, val name: String? = null) {
val empty: Boolean
get() = !file.exists() || file.length() == 0L

companion object Name {
const val SCREEN = "screen"
const val LOG = "log"
const val LOGCAT = "logcat"
const val XCODEBUILDLOG = "xcodebuild-log"
}
}

enum class AttachmentType(val mimeType: String) {
SCREENSHOT_GIF("image/gif"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ class LogListener(
private val devicePoolId: DevicePoolId,
private val testBatchId: String,
private val logWriter: LogWriter,
private val attachmentProvider: AttachmentProviderDelegate = AttachmentProviderDelegate()
private val attachmentProvider: AttachmentProviderDelegate = AttachmentProviderDelegate(),
private val attachmentName: String = Attachment.Name.LOG,
) : TestRunListener, AttachmentProvider by attachmentProvider, LineListener {
private val stringBuffer = StringBuffer(4096)

Expand All @@ -37,7 +38,7 @@ class LogListener(
stringBuffer.reset()
if (log.isNotEmpty()) {
val file = logWriter.saveLogs(test, devicePoolId, testBatchId, deviceInfo, listOf(log))
attachmentProvider.onAttachment(test, Attachment(file, AttachmentType.LOG))
attachmentProvider.onAttachment(test, Attachment(file, AttachmentType.LOG, attachmentName))
}
}

Expand Down
16 changes: 12 additions & 4 deletions core/src/main/kotlin/com/malinskiy/marathon/io/FileManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,16 @@ class FileManager(private val maxPath: Int, private val maxFilename: Int, privat
device: DeviceInfo,
test: Test? = null,
testBatchId: String? = null,
id: String? = null
id: String? = null,
chunk: String? = null,
): File {
val directory = when {
test != null || testBatchId != null -> createDirectory(fileType, pool, device)
else -> createDirectory(fileType, pool)
}
val filename = when {
test != null -> createTestFilename(test, fileType, testBatchId, id = id)
testBatchId != null -> createBatchFilename(testBatchId, fileType, id = id)
test != null -> createTestFilename(test, fileType, testBatchId, id = id, chunk = chunk)
testBatchId != null -> createBatchFilename(testBatchId, fileType, id = id, chunk = chunk)
else -> createDeviceFilename(device, fileType, id = id)
}
return createFile(directory, filename)
Expand Down Expand Up @@ -104,7 +105,7 @@ class FileManager(private val maxPath: Int, private val maxFilename: Int, privat
}
}

private fun createBatchFilename(testBatchId: String, fileType: FileType, id: String? = null): String {
private fun createBatchFilename(testBatchId: String, fileType: FileType, id: String? = null, chunk: String? = null): String {
return StringBuilder().apply {
append(testBatchId)
if (id != null) {
Expand All @@ -113,6 +114,9 @@ class FileManager(private val maxPath: Int, private val maxFilename: Int, privat
if (fileType.suffix.isNotEmpty()) {
append(".$testBatchId")
}
if (chunk != null) {
append("-$chunk")
}
}.toString()
}

Expand All @@ -122,6 +126,7 @@ class FileManager(private val maxPath: Int, private val maxFilename: Int, privat
testBatchId: String? = null,
overrideExtension: String? = null,
id: String? = null,
chunk: String? = null,
): String {
val testSuffix = StringBuilder().apply {
if (testBatchId != null) {
Expand All @@ -130,6 +135,9 @@ class FileManager(private val maxPath: Int, private val maxFilename: Int, privat
if (id != null) {
append("-$id")
}
if (chunk != null) {
append("-$chunk")
}
if (overrideExtension != null) {
append(".${overrideExtension}")
} else if (fileType.suffix.isNotEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,17 @@ class AllureReporter(val configuration: Configuration, private val outputDirecto
TestStatus.IGNORED -> Status.SKIPPED
}

val allureAttachments: List<Attachment> = testResult.attachments.map {
Attachment()
.setName(it.type.name.lowercase(Locale.ENGLISH)
.replaceFirstChar { cher -> if (cher.isLowerCase()) cher.titlecase(Locale.ENGLISH) else cher.toString() })
.setSource(it.file.absolutePath)
.setType(it.type.mimeType)
val allureAttachments: List<Attachment> = testResult.attachments.mapNotNull {
if (it.empty) {
null
} else {
val name = it.name ?: it.type.name.lowercase(Locale.ENGLISH)
.replaceFirstChar { cher -> if (cher.isLowerCase()) cher.titlecase(Locale.ENGLISH) else cher.toString() }
Attachment()
.setName(name)
.setSource(it.file.absolutePath)
.setType(it.type.mimeType)
}
}

val allureTestResult = io.qameta.allure.model.TestResult()
Expand Down
3 changes: 2 additions & 1 deletion docs/runner/android/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,8 @@ screenshots or configure the recording parameters you can specify this as follow

:::tip

Android's `screenrecorder` doesn't support videos longer than 180 seconds
Android's `screenrecorder` doesn't support videos longer than 180 seconds for apiVersion < 34. For such devices marathon will automatically
record as many video files as needed if the test takes longer than 180 seconds. Expect several missing frames between the files though

:::

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.malinskiy.marathon.device.DevicePoolId
import com.malinskiy.marathon.device.OperatingSystem
import com.malinskiy.marathon.device.toDeviceInfo
import com.malinskiy.marathon.exceptions.DeviceSetupException
import com.malinskiy.marathon.execution.Attachment
import com.malinskiy.marathon.execution.TestBatchResults
import com.malinskiy.marathon.extension.withTimeoutOrNull
import com.malinskiy.marathon.io.FileManager
Expand Down Expand Up @@ -252,7 +253,7 @@ abstract class BaseAndroidDevice(
} ?: NoOpTestRunListener()

val logListener = TestRunListenerAdapter(
LogListener(this.toDeviceInfo(), this, devicePoolId, testBatch.id, LogWriter(fileManager))
LogListener(this.toDeviceInfo(), this, devicePoolId, testBatch.id, LogWriter(fileManager), attachmentName = Attachment.Name.LOGCAT)
.also { attachmentProviders.add(it) }
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,17 @@ class RemoteFileManager(private val device: AndroidDevice) {
return remoteFileForTest(videoFileName(test, testBatchId))
}

fun remoteChunkedVideoForTest(test: Test, testBatchId: String, chunk: Long): String {
return remoteFileForTest(videoFileName(test, testBatchId, chunk))
}

private fun remoteFileForTest(filename: String): String {
return "$outputDir/$filename"
}

private fun videoFileName(test: Test, testBatchId: String): String {
"${test.toClassName()}-${test.method}-$testBatchId.mp4"

val testSuffix = "-$testBatchId.mp4"
private fun videoFileName(test: Test, testBatchId: String, chunk: Long? = null): String {
val chunkId = chunk?.let { "-$it" } ?: ""
val testSuffix = "-$testBatchId$chunkId.mp4"
val rawTestName = "${test.toClassName()}-${test.method}".escape()
val testName = rawTestName.take(MAX_FILENAME - testSuffix.length)
val fileName = "$testName$testSuffix"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,9 +383,11 @@ class AdamAndroidDevice(
remoteFilePath: String,
options: VideoConfiguration
) {
val screenRecorderCommand = options.toScreenRecorderCommand(remoteFilePath)
val supportsInfiniteRecording = apiLevel >= 34
val screenRecorderCommand = options.toScreenRecorderCommand(remoteFilePath, supportsInfiniteRecording)
try {
withTimeoutOrNull(androidConfiguration.timeoutConfiguration.screenrecorder) {
logger.debug { "Running screenrecorder for $remoteFilePath" }
val result = client.execute(ShellCommandRequest(screenRecorderCommand), serial = adbSerial)
logger.debug {
StringBuilder().apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,38 @@ class AdamScreenCaptureTestRunListener(
attachmentType = AttachmentType.SCREENSHOT_JPEG
fileType = FileType.SCREENSHOT_JPG
}

"png" -> {
attachmentType = AttachmentType.SCREENSHOT_PNG
fileType = FileType.SCREENSHOT_PNG
}

"webp" -> {
attachmentType = AttachmentType.SCREENSHOT_WEBP
fileType = FileType.SCREENSHOT_WEBP
}

"gif" -> {
attachmentType = AttachmentType.SCREENSHOT_GIF
fileType = FileType.SCREENSHOT_GIF
}

else -> Unit
}

if (attachmentType != null && fileType != null) {
val localFile = fileManager.createFile(fileType, pool, device.toDeviceInfo(), test.toTest(), testBatchId = testBatchId, id = UUID.randomUUID().toString())
val localFile = fileManager.createFile(
fileType,
pool,
device.toDeviceInfo(),
test.toTest(),
testBatchId = testBatchId,
id = UUID.randomUUID().toString()
)
device.safePullFile(path, localFile.absolutePath)
logger.debug { "Received screen capture file $path" }
attachmentListeners.forEach {
it.onAttachment(test.toTest(), Attachment(localFile, attachmentType))
it.onAttachment(test.toTest(), Attachment(localFile, attachmentType, Attachment.Name.SCREEN))
}
} else {
logger.warn { "Unable to decode image format of screen capture $path. The file will be available in the report directory, but will not be included as part of any visual report" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class ScreenCapturerTestRunListener(
} else {
attachmentListeners.forEach {
val file = fileManager.createFile(FileType.SCREENSHOT, pool, device.toDeviceInfo(), toTest, testBatchId)
val attachment = Attachment(file, AttachmentType.SCREENSHOT_GIF)
val attachment = Attachment(file, AttachmentType.SCREENSHOT_GIF, Attachment.Name.SCREEN)
it.onAttachment(toTest, attachment)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,19 @@ import kotlin.system.measureTimeMillis
internal class ScreenRecorder(
private val device: AndroidDevice,
private val videoConfiguration: VideoConfiguration,
private val remoteFilePath: String
) {

suspend fun run() {
suspend fun run(remoteFilePath: String) {
try {
startRecordingTestVideo()
startRecordingTestVideo(remoteFilePath)
} catch (e: CancellationException) {
logger.warn(e) { "screenrecord start was interrupted" }
} catch (e: Exception) {
logger.error("Something went wrong while screen recording", e)
}
}

private suspend fun startRecordingTestVideo() {
private suspend fun startRecordingTestVideo(remoteFilePath: String) {
val millis = measureTimeMillis {
device.safeStartScreenRecorder(
remoteFilePath = remoteFilePath,
Expand All @@ -32,7 +31,65 @@ internal class ScreenRecorder(
logger.debug { "Recording finished in ${millis}ms $remoteFilePath" }
}

suspend fun stopScreenRecord() {
logger.debug { "Stopping screen recorder" }
var hasKilledScreenRecord = true
for (tries in 0 until SCREEN_RECORD_KILL_ATTEMPTS) {
if (!hasKilledScreenRecord) break
hasKilledScreenRecord = attemptToGracefullyKillScreenRecord()
pauseBetweenProcessKill()
}
}

private suspend fun grepPid(): String {
val output = if (device.version.isGreaterOrEqualThan(26)) {
device.safeExecuteShellCommand("ps -A | grep screenrecord")?.output ?: ""
} else {
device.safeExecuteShellCommand("ps | grep screenrecord")?.output ?: ""
}

if (output.isBlank()) {
return ""
}

val lastLine = output.lines().last { it.isNotEmpty() }
val split = lastLine.split(' ').filter { it.isNotBlank() }
val pid = split.getOrNull(1)?.let { it.toIntOrNull()?.toString() } ?: ""
logger.trace("Extracted PID {} from output {}", pid, output)
return pid
}

private suspend fun attemptToGracefullyKillScreenRecord(): Boolean {
try {
val pid = grepPid()
if (pid.isNotBlank()) {
logger.trace("Killing PID {} on {}", pid, device.serialNumber)
device.safeExecuteShellCommand("kill -2 $pid")
return true
} else {
logger.warn { "Did not kill any screen recording process" }
}
} catch (e: Exception) {
logger.error("Error while killing recording processes", e)
}
return false
}

private fun pauseBetweenProcessKill() {
try {
Thread.sleep(PAUSE_BETWEEN_RECORDER_PROCESS_KILL.toLong())
} catch (ignored: InterruptedException) {
logger.warn(ignored) { "screenrecord stop was interrupted" }
}

}

companion object {
private val logger = MarathonLogging.logger("ScreenRecorder")
private const val SCREEN_RECORD_KILL_ATTEMPTS = 5
/*
* Workaround for https://github.com/MarathonLabs/marathon/issues/133
*/
private const val PAUSE_BETWEEN_RECORDER_PROCESS_KILL = 300
}
}

This file was deleted.

Loading

0 comments on commit b5ea9c6

Please sign in to comment.