Skip to content

Commit

Permalink
feat: Instrument test console log parser (#1824)
Browse files Browse the repository at this point in the history
  • Loading branch information
pawelpasterz authored Apr 22, 2021
1 parent da06aa9 commit 98efc47
Show file tree
Hide file tree
Showing 6 changed files with 2,029 additions and 1 deletion.
18 changes: 18 additions & 0 deletions corellium/log/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
kotlin(Plugins.Kotlin.PLUGIN_JVM)
kotlin(Plugins.Kotlin.PLUGIN_SERIALIZATION) version Versions.KOTLIN
}

repositories {
mavenCentral()
maven(url = "https://kotlin.bintray.com/kotlinx")
}

tasks.withType<KotlinCompile> { kotlinOptions.jvmTarget = "1.8" }

dependencies {
implementation(Dependencies.KOTLIN_COROUTINES_CORE)
testImplementation(Dependencies.JUNIT)
}
144 changes: 144 additions & 0 deletions corellium/log/src/main/kotlin/flank/corellium/log/Internal.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package flank.corellium.log

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform

// Stage 1 ============================================
/**
* Group instrumentation output logs
* @receiver Flow of lines
* @return Flow of groups where the last line always contains status code
*/
internal fun Flow<String>.groupLines(): Flow<List<Line>> {
val accumulator = mutableListOf<Line>()
return transform { line ->
val (prefix, text) = line.parsePrefix()
accumulator += Line(prefix, text)
when (prefix) {
Type.StatusCode.text,
Type.Code.text -> {
emit(accumulator.toList())
accumulator.clear()
}
}
}
}

/**
* Parsed line of instrumentation output. For example:
* ```
* INSTRUMENTATION_STATUS: test=ignoredTestWithIgnore
* ```
* @property prefix `"INSTRUMENTATION_STATUS: "`
* @property text `"test=ignoredTestWithIgnore"`
*/
internal data class Line(
val prefix: String?,
val text: String,
)

private fun String.parsePrefix(): Pair<String?, String> {
val prefix = Type.values().firstOrNull { startsWith(it.text) }?.text
return prefix to (prefix?.let { removePrefix(it) } ?: this)
}

private enum class Type(val text: String) {
Status("INSTRUMENTATION_STATUS: "),
StatusCode("INSTRUMENTATION_STATUS_CODE: "),
Result("INSTRUMENTATION_RESULT: "),
Code("INSTRUMENTATION_CODE: "),
}

// Stage 2 ============================================
/**
* Parse previously grouped lines into chunks
* @receiver [Flow] of [Line] groups
* @return [Flow] of [Chunk]
*/
internal fun Flow<List<Line>>.parseChunks(): Flow<Chunk> = map { group ->
val reversed = group.reversed().toMutableList()
val code = reversed.removeFirst()
val linesAccumulator = mutableListOf<String>()
val map = mutableMapOf<String, List<String>>()
reversed.forEach { line ->
if (line.prefix == null)
linesAccumulator += line.text
else
linesAccumulator.apply {
val (key, text) = line.text.split("=", limit = 2)
add(text)
map[key] = reversed()
clear()
}
}
Chunk(code.prefix!!, code.text.toInt(), map)
}

/**
* The structured representation of instrumentation output lines followed by result code.
* @property type The prefix of status code line: [INSTRUMENTATION_STATUS_CODE | INSTRUMENTATION_CODE]
* @property code The result code.
* @property map The properties for the specific group of lines.
*/
internal data class Chunk(
val type: String,
val code: Int,
val map: Map<String, List<String>>,
val timestamp: Long = System.currentTimeMillis(),
)

// Stage 3 ============================================

internal fun Flow<Chunk>.parseStatusResult(): Flow<Instrument> {
var prev = Chunk(
type = "",
code = 0,
map = mapOf("current" to listOf("0"))
)

return transform { next ->
when (next.type) {

// Handling the regular chunk which is representing the half of Status.
Type.StatusCode.text -> when {
prev.id == next.id -> emit(createStatus(prev, next))
prev.id < next.id -> prev = next
else -> throw IllegalArgumentException("Inconsistent stream of chunks.\nexpected pair for: $prev\nbut was $next")
}

// Handling the final chunk which is representing the Result.
Type.Code.text -> emit(createResult(next))

else -> throw IllegalArgumentException("Unknown type of Chunk: ${next.type}")
}
}
}

private val Chunk.id: Int get() = map.getValue("current").first().toInt()

private fun createStatus(first: Chunk, second: Chunk) = Instrument.Status(
code = second.code,
startTime = first.timestamp,
endTime = second.timestamp,
details = (first.map + second.map).mapValues { (key, value) ->
when (key) {
"id",
"test",
"class",
-> value.first()

"current",
"numTests",
-> value.first().toInt()

else -> value
}
}
)

private fun createResult(chunk: Chunk) = Instrument.Result(
code = chunk.code,
details = chunk.map,
time = chunk.timestamp
)
73 changes: 73 additions & 0 deletions corellium/log/src/main/kotlin/flank/corellium/log/Parser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package flank.corellium.log

import kotlinx.coroutines.flow.Flow

/**
* Parse instrument test logs into structures.
* This parser requires a clean logs only from the single command execution.
*
* @receiver The [Flow] of [String] lines from console output produced by "am instrument -r -w" command.
* @return The [Flow] of [Instrument] structures. Only the last element of flow is [Instrument.Result]
*/
fun Flow<String>.parseAdbInstrumentLog(): Flow<Instrument> = this
.groupLines()
.parseChunks()
.parseStatusResult()

sealed class Instrument {
/**
* Representation of the following pair of two status chunks:
* ```
* INSTRUMENTATION_STATUS: class=com.example.test_app.InstrumentedTest
* INSTRUMENTATION_STATUS: current=1
* INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
* INSTRUMENTATION_STATUS: numtests=3
* INSTRUMENTATION_STATUS: stream=
* com.example.test_app.InstrumentedTest:
* INSTRUMENTATION_STATUS: test=ignoredTestWithIgnore
* INSTRUMENTATION_STATUS_CODE: 1
* INSTRUMENTATION_STATUS: class=com.example.test_app.InstrumentedTest
* INSTRUMENTATION_STATUS: current=1
* INSTRUMENTATION_STATUS: id=AndroidJUnitRunner
* INSTRUMENTATION_STATUS: numtests=3
* INSTRUMENTATION_STATUS: stream=
* com.example.test_app.InstrumentedTest:
* INSTRUMENTATION_STATUS: test=ignoredTestWithIgnore
* INSTRUMENTATION_STATUS_CODE: -3
* ```
*
* @property code The value of INSTRUMENTATION_STATUS_CODE of the second chunk
* @property startTime The time of creation the first chunk of status.
* @property endTime The time of creation the second chunk of status.
* @property details The summary details of both chunks.
*/
class Status(
val code: Int,
val startTime: Long,
val endTime: Long,
val details: Map<String, Any>
) : Instrument()

/**
* Representation of the final structure of instrument test logs:
* ```
* INSTRUMENTATION_RESULT: stream=
*
* Time: 2.076
*
* OK (2 test)
*
*
* INSTRUMENTATION_CODE: -1
* ```
*
* @property code The value of INSTRUMENTATION_CODE
* @property time The time of creation the result chunk.
* @property details The details recorded for the result (Perhaps only a "stream" value).
*/
class Result(
val code: Int,
val time: Long,
val details: Map<String, Any>
) : Instrument()
}
67 changes: 67 additions & 0 deletions corellium/log/src/test/kotlin/flank/corellium/log/InternalTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package flank.corellium.log

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test

class InternalTest {

@Test
fun groupLinesTest() {
val lines = runBlocking {
flowLogs()
.groupLines()
.toList()
}

// 2 * 29 statuses + 1 result
Assert.assertEquals(59, lines.size)
}

@Test
fun parseChunksTest() {
val chunks = runBlocking {
flowLogs()
.groupLines()
.parseChunks()
.toList()
}

// 2 * 29 statuses + 1 result
Assert.assertEquals(59, chunks.size)
}

@Test
fun parseInstrumentStatus() {
val statusResults = runBlocking {
flowLogs()
.groupLines()
.parseChunks()
.parseStatusResult()
.toList()
}

// 29 statuses + 1 result
Assert.assertEquals(30, statusResults.size)

Assert.assertTrue(
"All items except the last one must be Instrument.Status",
statusResults.dropLast(1).all { it is Instrument.Status }
)

Assert.assertTrue(
"Last item must be Instrument.Result",
statusResults.last() is Instrument.Result
)
}
}

private fun flowLogs(): Flow<String> =
Unit.javaClass.classLoader
.getResourceAsStream("example_android_logs.txt")!!
.bufferedReader()
.lineSequence()
.asFlow()
Loading

0 comments on commit 98efc47

Please # to comment.