diff --git a/.gitignore b/.gitignore index ac253f5..7d91cac 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ coverage/ npm-debug.log* yarn-debug.log* yarn-error.log* +composer/src/main/resources/html-report diff --git a/ci/build.sh b/ci/build.sh index 4ca52f4..48ea7c5 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -16,7 +16,7 @@ fi docker build -t composer:latest ci/docker -BUILD_COMMAND="" +BUILD_COMMAND="set -e && " BUILD_COMMAND+="echo 'Java version:' && java -version && " BUILD_COMMAND+="echo 'Node.js version:' && node --version && " @@ -28,7 +28,10 @@ BUILD_COMMAND+="cd /opt/project/html-report && " BUILD_COMMAND+="rm -rf node_modules && " BUILD_COMMAND+="npm install && " BUILD_COMMAND+="npm run build && " -BUILD_COMMAND+="cd - && " +BUILD_COMMAND+="cd /opt/project && " +BUILD_COMMAND+="rm -rf composer/src/main/resources/html-report/ && " +BUILD_COMMAND+="mkdir -p composer/src/main/resources/html-report/ && " +BUILD_COMMAND+="cp -R html-report/build/* composer/src/main/resources/html-report/ && " # Build Composer. BUILD_COMMAND+="echo 'Building Composer...' && " diff --git a/composer/src/main/kotlin/com/gojuno/composer/Main.kt b/composer/src/main/kotlin/com/gojuno/composer/Main.kt index 737a422..d39dfac 100644 --- a/composer/src/main/kotlin/com/gojuno/composer/Main.kt +++ b/composer/src/main/kotlin/com/gojuno/composer/Main.kt @@ -10,6 +10,7 @@ import com.google.gson.Gson import rx.Observable import rx.schedulers.Schedulers import java.io.File +import java.util.* sealed class Exit(val code: Int, val message: String?) { object Ok : Exit(code = 0, message = null) @@ -113,7 +114,7 @@ fun main(rawArgs: Array) { } } .flatMap { suites -> - writeHtmlReport(gson, suites, File(args.outputDirectory, "html-report")) + writeHtmlReport(gson, suites, File(args.outputDirectory, "html-report"), Date()) .andThen(Observable.just(suites)) } .toBlocking() diff --git a/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullSuite.kt b/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullSuite.kt index 3cab27e..bab1e9e 100644 --- a/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullSuite.kt +++ b/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullSuite.kt @@ -31,7 +31,7 @@ data class HtmlFullSuite( fun Suite.toHtmlFullSuite(id: String, htmlReportDir: File) = HtmlFullSuite( id = id, - tests = tests.map { it.toHtmlFullTest(htmlReportDir).toHtmlShortTest() }, + tests = tests.map { it.toHtmlFullTest(suiteId = id, htmlReportDir = htmlReportDir).toHtmlShortTest() }, passedCount = passedCount, ignoredCount = ignoredCount, failedCount = failedCount, diff --git a/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullTest.kt b/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullTest.kt index 1faf941..cfd3b9b 100644 --- a/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullTest.kt +++ b/composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullTest.kt @@ -7,6 +7,9 @@ import java.util.concurrent.TimeUnit.NANOSECONDS data class HtmlFullTest( + @SerializedName("suite_id") + val suiteId: String, + @SerializedName("package_name") val packageName: String, @@ -56,7 +59,8 @@ data class HtmlFullTest( } } -fun AdbDeviceTest.toHtmlFullTest(htmlReportDir: File) = HtmlFullTest( +fun AdbDeviceTest.toHtmlFullTest(suiteId: String, htmlReportDir: File) = HtmlFullTest( + suiteId = suiteId, packageName = className.substringBeforeLast("."), className = className.substringAfterLast("."), name = testName, diff --git a/composer/src/main/kotlin/com/gojuno/composer/html/HtmlReport.kt b/composer/src/main/kotlin/com/gojuno/composer/html/HtmlReport.kt index c2705db..b9e00df 100644 --- a/composer/src/main/kotlin/com/gojuno/composer/html/HtmlReport.kt +++ b/composer/src/main/kotlin/com/gojuno/composer/html/HtmlReport.kt @@ -2,8 +2,12 @@ package com.gojuno.composer.html import com.gojuno.composer.Suite import com.google.gson.Gson +import org.apache.commons.lang3.StringEscapeUtils import rx.Completable import java.io.File +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.* /** * Following file tree structure will be created: @@ -11,7 +15,7 @@ import java.io.File * - suites/suiteId.json * - suites/deviceId/testId.json */ -fun writeHtmlReport(gson: Gson, suites: List, outputDir: File): Completable = Completable.fromCallable { +fun writeHtmlReport(gson: Gson, suites: List, outputDir: File, date: Date): Completable = Completable.fromCallable { outputDir.mkdirs() val htmlIndexJson = gson.toJson( @@ -20,18 +24,62 @@ fun writeHtmlReport(gson: Gson, suites: List, outputDir: File): Completab ) ) - File(outputDir, "index.json").writeText(htmlIndexJson) + + val date = SimpleDateFormat("HH:mm:ss z, MMM d yyyy").apply { timeZone = TimeZone.getTimeZone("UTC") }.format(date) + + val appJs = File(outputDir, "app.min.js") + inputStreamFromResources("html-report/app.min.js").copyTo(appJs.outputStream()) + + val appCss = File(outputDir, "app.min.css") + inputStreamFromResources("html-report/app.min.css").copyTo(appCss.outputStream()) + + // index.html is a page that can render all kinds of inner pages: Index, Suite, Test. + val indexHtml = inputStreamFromResources("html-report/index.html").reader().readText() + + val indexHtmlFile = File(outputDir, "index.html") + + fun File.relativePathToHtmlDir(): String = outputDir.relativePathTo(this.parentFile).let { relativePath -> + when (relativePath) { + "" -> relativePath + else -> "$relativePath/" + } + } + + indexHtmlFile.writeText(indexHtml + .replace("\${relative_path}", indexHtmlFile.relativePathToHtmlDir()) + .replace("\${data_json}", "window.mainData = $htmlIndexJson") + .replace("\${date}", date) + .replace("\${log}", "") + ) val suitesDir = File(outputDir, "suites").apply { mkdirs() } suites.mapIndexed { suiteId, suite -> - File(suitesDir, "$suiteId.json").writeText(gson.toJson(suite.toHtmlFullSuite(id = "$suiteId", htmlReportDir = suitesDir))) + val suiteJson = gson.toJson(suite.toHtmlFullSuite(id = "$suiteId", htmlReportDir = suitesDir)) + val suiteHtmlFile = File(suitesDir, "$suiteId.html") + + suiteHtmlFile.writeText(indexHtml + .replace("\${relative_path}", suiteHtmlFile.relativePathToHtmlDir()) + .replace("\${data_json}", "window.suite = $suiteJson") + .replace("\${date}", date) + .replace("\${log}", "") + ) suite .tests .map { it to File(File(suitesDir, "$suiteId"), it.adbDevice.id).apply { mkdirs() } } - .map { (test, testDir) -> test.toHtmlFullTest(htmlReportDir = testDir) to testDir } - .forEach { (htmlFullTest, testDir) -> File(testDir, "${htmlFullTest.id}.json").writeText(gson.toJson(htmlFullTest)) } + .map { (test, testDir) -> Triple(test, test.toHtmlFullTest(suiteId = "$suiteId", htmlReportDir = testDir), testDir) } + .forEach { (test, htmlTest, testDir) -> + val testJson = gson.toJson(htmlTest) + val testHtmlFile = File(testDir, "${htmlTest.id}.html") + + testHtmlFile.writeText(indexHtml + .replace("\${relative_path}", testHtmlFile.relativePathToHtmlDir()) + .replace("\${data_json}", "window.test = $testJson") + .replace("\${date}", date) + .replace("\${log}", generateLogcatHtml(test.logcat)) + ) + } } } @@ -40,3 +88,31 @@ fun writeHtmlReport(gson: Gson, suites: List, outputDir: File): Completab * See https://youtrack.jetbrains.com/issue/KT-14056 */ fun File.relativePathTo(base: File): String = absoluteFile.toRelativeString(base.absoluteFile) + +fun inputStreamFromResources(path: String): InputStream = Suite::class.java.classLoader.getResourceAsStream(path) + +fun generateLogcatHtml(logcatOutput: File): String = when (logcatOutput.exists()) { + false -> "" + true -> logcatOutput + .readLines() + .map { line -> """
${StringEscapeUtils.escapeXml11(line)}
""" } + .fold(StringBuilder("""
""")) { stringBuilder, line -> + stringBuilder.appendln(line) + } + .let { it.appendln("""
""") } + .let { it.toString() } +} + +fun cssClassForLogcatLine(logcatLine: String): String { + // Logcat line example: `06-07 16:55:14.490 2100 2100 I MicroDetectionWorker: #onError(false)` + // First letter is Logcat level. + return when (logcatLine.firstOrNull { it.isLetter() }) { + 'V' -> "verbose" + 'D' -> "debug" + 'I' -> "info" + 'W' -> "warning" + 'E' -> "error" + 'A' -> "assert" + else -> "default" + } +} diff --git a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullSuiteSpec.kt b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullSuiteSpec.kt index 96e662f..22e21f0 100644 --- a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullSuiteSpec.kt +++ b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullSuiteSpec.kt @@ -59,7 +59,7 @@ class HtmlFullSuiteSpec : Spek({ failedCount = suite.failedCount, durationMillis = NANOSECONDS.toMillis(suite.durationNanos), devices = suite.devices.map { it.toHtmlDevice(htmlReportDir = testFile()) }, - tests = suite.tests.map { it.toHtmlFullTest(htmlReportDir = testFile()).toHtmlShortTest() } + tests = suite.tests.map { it.toHtmlFullTest(suiteId = "testSuite", htmlReportDir = testFile()).toHtmlShortTest() } )) } } diff --git a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullTestSpec.kt b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullTestSpec.kt index d67667b..1cda2a0 100644 --- a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullTestSpec.kt +++ b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullTestSpec.kt @@ -24,10 +24,11 @@ class HtmlFullTestSpec : Spek({ screenshots = listOf(testFile(), testFile()) ) - val htmlTest = adbDeviceTest.toHtmlFullTest(htmlReportDir = testFile().parentFile) + val htmlTest = adbDeviceTest.toHtmlFullTest(suiteId = "testSuite", htmlReportDir = testFile().parentFile) it("converts AdbDeviceTest to HtmlFullTest") { assertThat(htmlTest).isEqualTo(HtmlFullTest( + suiteId = "testSuite", packageName = "com.gojuno.example", className = "TestClass", name = adbDeviceTest.testName, diff --git a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlReportSpec.kt b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlReportSpec.kt index 0718346..610f005 100644 --- a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlReportSpec.kt +++ b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlReportSpec.kt @@ -13,6 +13,7 @@ import org.jetbrains.spek.api.dsl.context import org.jetbrains.spek.api.dsl.it import rx.observers.TestSubscriber import java.io.File +import java.util.* import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.TimeUnit.SECONDS @@ -68,12 +69,14 @@ class HtmlReportSpec : Spek({ fun File.deleteOnExitRecursively() { when (isDirectory) { false -> deleteOnExit() - true -> listFiles()?.forEach { inner -> inner.deleteOnExitRecursively()} + true -> listFiles()?.forEach { inner -> inner.deleteOnExitRecursively() } } } + val date by memoized { Date(1496848677000) } + perform { - writeHtmlReport(Gson(), suites, outputDir).subscribe(subscriber) + writeHtmlReport(Gson(), suites, outputDir, date).subscribe(subscriber) subscriber.awaitTerminalEvent(5, SECONDS) outputDir.deleteOnExitRecursively() } @@ -86,28 +89,176 @@ class HtmlReportSpec : Spek({ subscriber.assertNoErrors() } - it("creates index.json") { - assertThat(File(outputDir, "index.json").readText()).isEqualTo( - """{"suites":[{"id":"0","passed_count":2,"ignored_count":0,"failed_count":1,"duration_millis":2468,"devices":[{"id":"device1","logcat_path":"device1.logcat","instrumentation_output_path":"device1.instrumentation"}]}]}""" + fun String.removeEmptyLines() = lines().filter { it.trim() != "" }.joinToString(separator = "\n") { it } + + it("creates index html") { + assertThat(File(outputDir, "index.html").readText().removeEmptyLines()).isEqualTo( + """ + + + + + + Composer + + + + +
+ + +
Generated with ❤️  by Juno at 15:17:57 UTC, Jun 7 2017
+ + + """.removeEmptyLines().trimIndent() ) } - it("creates suite json") { - assertThat(File(File(outputDir, "suites"), "0.json").readText()).isEqualTo( - """{"id":"0","tests":[{"id":"com.gojuno.example1TestClasstest1","package_name":"com.gojuno.example1","class_name":"TestClass","name":"test1","duration_millis":1234,"status":"passed","deviceId":"device1","properties":{}},{"id":"com.gojuno.example1TestClasstest2","package_name":"com.gojuno.example1","class_name":"TestClass","name":"test2","duration_millis":1234,"status":"failed","deviceId":"device1","properties":{}}],"passed_count":2,"ignored_count":0,"failed_count":1,"duration_millis":2468,"devices":[{"id":"device1","logcat_path":"../device1.logcat","instrumentation_output_path":"../device1.instrumentation"}]}""" + it("creates suite html") { + assertThat(File(File(outputDir, "suites"), "0.html").readText().removeEmptyLines()).isEqualTo( + """ + + + + + + Composer + + + + +
+ + +
Generated with ❤️  by Juno at 15:17:57 UTC, Jun 7 2017
+ + + """.removeEmptyLines().trimIndent() ) } - it("creates json for 1st test") { - assertThat(File(File(File(File(outputDir, "suites"), "0"), "device1"), "com.gojuno.example1TestClasstest1.json").readText()).isEqualTo( - """{"package_name":"com.gojuno.example1","class_name":"TestClass","name":"test1","id":"com.gojuno.example1TestClasstest1","duration_millis":1234,"status":"passed","logcat_path":"../../../com.gojuno.example1.TestClass/test1.logcat","deviceId":"device1","properties":{},"file_paths":["../../../com.gojuno.example1.TestClass.test1/file1","../../../com.gojuno.example1.TestClass.test1/file2"],"screenshots_paths":["../../../com.gojuno.example1.TestClass.test1/screenshot1","../../../com.gojuno.example1.TestClass.test1/screenshot2"]}""" + it("creates html for 1st test") { + assertThat(File(File(File(File(outputDir, "suites"), "0"), "device1"), "com.gojuno.example1TestClasstest1.html").readText().removeEmptyLines()).isEqualTo( + """ + + + + + + Composer + + + + +
+ + +
Generated with ❤️  by Juno at 15:17:57 UTC, Jun 7 2017
+ + + """.removeEmptyLines().trimIndent() ) } - it("creates json for 2nd test") { - assertThat(File(File(File(File(outputDir, "suites"), "0"), "device1"), "com.gojuno.example1TestClasstest2.json").readText()).isEqualTo( - """{"package_name":"com.gojuno.example1","class_name":"TestClass","name":"test2","id":"com.gojuno.example1TestClasstest2","duration_millis":1234,"status":"failed","stacktrace":"abc","logcat_path":"../../../com.gojuno.example1.TestClass/test2.logcat","deviceId":"device1","properties":{},"file_paths":["../../../com.gojuno.example1.TestClass.test2/file1","../../../com.gojuno.example1.TestClass.test2/file2"],"screenshots_paths":["../../../com.gojuno.example1.TestClass.test2/screenshot1","../../../com.gojuno.example1.TestClass.test2/screenshot2"]}""" + it("creates html for 2nd test") { + assertThat(File(File(File(File(outputDir, "suites"), "0"), "device1"), "com.gojuno.example1TestClasstest2.html").readText().removeEmptyLines()).isEqualTo( + """ + + + + + + Composer + + + + +
+ + +
Generated with ❤️  by Juno at 15:17:57 UTC, Jun 7 2017
+ + + """.removeEmptyLines().trimIndent() ) } } + + context("cssClassForLogcatLine") { + + context("verbose") { + + val cssClass by memoized { cssClassForLogcatLine("06-07 16:55:14.490 2100 2100 V MicroDetectionWorker: #onError(false)") } + + it("is verbose") { + assertThat(cssClass).isEqualTo("verbose") + } + } + + context("debug") { + + val cssClass by memoized { cssClassForLogcatLine("06-07 16:55:14.490 2100 2100 D MicroDetectionWorker: #onError(false)") } + + it("is debug") { + assertThat(cssClass).isEqualTo("debug") + } + } + + context("info") { + + val cssClass by memoized { cssClassForLogcatLine("06-07 16:55:14.490 2100 2100 I MicroDetectionWorker: #onError(false)") } + + it("is info") { + assertThat(cssClass).isEqualTo("info") + } + } + + context("warning") { + + val cssClass by memoized { cssClassForLogcatLine("06-07 16:55:14.490 2100 2100 W MicroDetectionWorker: #onError(false)") } + + it("is warning") { + assertThat(cssClass).isEqualTo("warning") + } + } + + context("error") { + + val cssClass by memoized { cssClassForLogcatLine("06-07 16:55:14.490 2100 2100 E MicroDetectionWorker: #onError(false)") } + + it("is error") { + assertThat(cssClass).isEqualTo("error") + } + } + + context("assert") { + + val cssClass by memoized { cssClassForLogcatLine("06-07 16:55:14.490 2100 2100 A MicroDetectionWorker: #onError(false)") } + + it("is assert") { + assertThat(cssClass).isEqualTo("assert") + } + } + + context("default") { + + val cssClass by memoized { cssClassForLogcatLine("06-07 16:55:14.490 2100 2100 U MicroDetectionWorker: #onError(false)") } + + it("is default") { + assertThat(cssClass).isEqualTo("default") + } + } + } }) diff --git a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlShortTestSpec.kt b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlShortTestSpec.kt index 3e882ef..f71ea4a 100644 --- a/composer/src/test/kotlin/com/gojuno/composer/html/HtmlShortTestSpec.kt +++ b/composer/src/test/kotlin/com/gojuno/composer/html/HtmlShortTestSpec.kt @@ -11,6 +11,7 @@ class HtmlShortTestSpec : Spek({ val htmlFullTest by memoized { HtmlFullTest( + suiteId = "testSuite", packageName = "com.gojuno.example", className = "TestClass", name = "test1", diff --git a/html-report/layout/index.html b/html-report/layout/index.html index d77958d..9ac5339 100644 --- a/html-report/layout/index.html +++ b/html-report/layout/index.html @@ -4,15 +4,15 @@ Composer - +
- + ${log}
Generated with ❤️  by Juno at ${date}