Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Instrument test console log parser #1824

Merged
merged 9 commits into from
Apr 22, 2021
Merged
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
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