├── settings.gradle ├── CHANGELOG.md ├── demo ├── composer.gif ├── screenshot1.png ├── screenshot2.png └── screenshot3.png ├── html-report ├── layout │ ├── log-entry.html │ ├── log-container.html │ └── index.html ├── .babelrc ├── styles │ ├── helpers │ │ ├── index.scss │ │ ├── _variables.scss │ │ ├── _mixins.scss │ │ ├── _colors.scss │ │ └── _fonts.scss │ ├── index.scss │ ├── _base.scss │ ├── _components.scss │ ├── _custom.scss │ ├── _layout.scss │ ├── _form.scss │ └── _elements.scss ├── webpack.config.js ├── src │ ├── index.js │ ├── utils │ │ ├── paths.js │ │ └── convertTime.js │ ├── App.js │ └── components │ │ ├── Suite.js │ │ ├── SuitesList.js │ │ ├── TestItem.js │ │ └── SearchBar.js ├── webpack.config.dev.js ├── postcss.config.js ├── webpack.config.prod.js └── package.json ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── composer ├── src │ ├── test │ │ ├── resources │ │ │ ├── instrumentation-test.apk │ │ │ ├── instrumentation-output-0-tests.txt │ │ │ ├── instrumentation-output-app-crash.txt │ │ │ ├── instrumentation-output-unable-to-find-instrumentation-info.txt │ │ │ ├── instrumentation-output-ignored-test.txt │ │ │ ├── instrumentation-unordered-output.txt │ │ │ ├── instrumentation-output-assumption-violation.txt │ │ │ └── instrumentation-output-failed-test.txt │ │ └── kotlin │ │ │ └── com │ │ │ └── gojuno │ │ │ └── composer │ │ │ ├── test.kt │ │ │ ├── html │ │ │ ├── HtmlDeviceSpec.kt │ │ │ ├── HtmlShortTestSpec.kt │ │ │ ├── HtmlFullTestSpec.kt │ │ │ ├── HtmlFullSuiteSpec.kt │ │ │ └── HtmlShortSuiteSpec.kt │ │ │ ├── ApkSpec.kt │ │ │ ├── LogLineParserSpec.kt │ │ │ ├── JUnitReportSpec.kt │ │ │ └── ArgsSpec.kt │ └── main │ │ └── kotlin │ │ └── com │ │ └── gojuno │ │ └── composer │ │ ├── TestMethod.kt │ │ ├── html │ │ ├── HtmlIndex.kt │ │ ├── HtmlDevice.kt │ │ ├── HtmlShortSuite.kt │ │ ├── HtmlShortTest.kt │ │ ├── HtmlFullSuite.kt │ │ ├── HtmlFullTest.kt │ │ └── HtmlReport.kt │ │ ├── Files.kt │ │ ├── Apk.kt │ │ ├── JUnitReport.kt │ │ ├── Args.kt │ │ ├── Instrumentation.kt │ │ ├── TestRun.kt │ │ └── Main.kt └── build.gradle ├── .travis.yml ├── .gitignore ├── ci ├── docker │ ├── entrypoint.sh │ └── Dockerfile └── build.sh ├── dependencies.gradle ├── gradlew.bat ├── spec └── REPORT.md ├── gradlew ├── README.md └── LICENSE.txt /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':composer' 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # See [Releases](https://github.com/gojuno/composer/releases) 2 | -------------------------------------------------------------------------------- /demo/composer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gojuno/composer/HEAD/demo/composer.gif -------------------------------------------------------------------------------- /demo/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gojuno/composer/HEAD/demo/screenshot1.png -------------------------------------------------------------------------------- /demo/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gojuno/composer/HEAD/demo/screenshot2.png -------------------------------------------------------------------------------- /demo/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gojuno/composer/HEAD/demo/screenshot3.png -------------------------------------------------------------------------------- /html-report/layout/log-entry.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gojuno/composer/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /html-report/layout/log-container.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | ${log_entries} 4 |
5 |
6 | -------------------------------------------------------------------------------- /html-report/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets":[ 3 | "latest", "react" 4 | ], 5 | "plugins": [ 6 | "transform-class-properties" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-test.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gojuno/composer/HEAD/composer/src/test/resources/instrumentation-test.apk -------------------------------------------------------------------------------- /html-report/styles/helpers/index.scss: -------------------------------------------------------------------------------- 1 | @import "./_variables.scss"; 2 | @import "./_colors.scss"; 3 | @import "./_mixins.scss"; 4 | @import "./_fonts.scss"; 5 | -------------------------------------------------------------------------------- /html-report/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(env) { 2 | return require('./webpack.config.' + env + '.js')({ 3 | env: env 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/TestMethod.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | data class TestMethod(val testName: String, val annotationNames: List) -------------------------------------------------------------------------------- /html-report/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById('root') 8 | ); 9 | -------------------------------------------------------------------------------- /html-report/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "./helpers/index.scss"; 2 | @import "./_base.scss"; 3 | @import "./_layout.scss"; 4 | @import "./_components.scss"; 5 | @import "./_elements.scss"; 6 | @import "./_form.scss"; 7 | @import "./_custom.scss"; 8 | -------------------------------------------------------------------------------- /html-report/src/utils/paths.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | fromIndexToSuite: (suiteId) => `./suites/${suiteId}.html`, 3 | fromSuiteToIndex: '../index.html', 4 | fromTestToSuite: (suiteId) => `../../${suiteId}.html`, 5 | fromTestToIndex: '../../../index.html', 6 | }; 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Apr 23 00:56:17 PDT 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.7-all.zip 7 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/html/HtmlIndex.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class HtmlIndex( 6 | 7 | @SerializedName("suites") 8 | val suites: List 9 | ) 10 | -------------------------------------------------------------------------------- /html-report/styles/helpers/_variables.scss: -------------------------------------------------------------------------------- 1 | // MediaQueries 2 | $small-screen: screen and (max-width: 1199px); //$tiny-screen 3 | $medium-screen: screen and (max-width: 1679px); //$small-screen 4 | $large-screen: screen and (min-width: 1680px); 5 | 6 | // Transition speed 7 | $short-transition: 0.15s 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: shell 2 | 3 | # We use Docker → we need sudo. 4 | sudo: required 5 | 6 | services: 7 | - docker 8 | 9 | script: 10 | - ci/build.sh 11 | 12 | deploy: 13 | - provider: script 14 | script: PUBLISH=true ci/build.sh 15 | skip_cleanup: true 16 | on: 17 | tags: true 18 | 19 | notifications: 20 | email: false 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | /.idea/ 4 | *.iml 5 | .DS_Store 6 | build/ 7 | /artifacts 8 | /captures 9 | /gradle/local.properties 10 | /docs 11 | /composer-output* 12 | 13 | # Share code style. 14 | !.idea/codeStyleSettings.xml 15 | 16 | # Html viewer rules 17 | node_modules/ 18 | npm-debug.log* 19 | /test/ 20 | /composer/src/main/resources/html-report/ 21 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-output-0-tests.txt: -------------------------------------------------------------------------------- 1 | Script started on Fri Mar 31 15:28:24 2017 2 | command: /usr/local/opt/android-sdk/platform-tools/adb -s emulator-5556 shell am instrument -w -r -e numShards 2 -e shardIndex 0 -e 3 | INSTRUMENTATION_RESULT: stream= 4 | 5 | Time: 0 6 | 7 | OK (0 tests) 8 | 9 | 10 | INSTRUMENTATION_CODE: -1 11 | 12 | Script done on Fri Mar 31 15:28:27 2017 13 | -------------------------------------------------------------------------------- /html-report/src/utils/convertTime.js: -------------------------------------------------------------------------------- 1 | module.exports = (time) => { 2 | let ms = time % 1000; 3 | time = (time - ms) / 1000; 4 | const secs = time % 60; 5 | time = (time - secs) / 60; 6 | const mins = time % 60; 7 | 8 | const msLenth = ms.toString().length; 9 | if (msLenth === 2 ) ms = '0'+ms; 10 | if (msLenth === 1 ) ms = '00'+ms; 11 | 12 | return mins + ':' + secs + '.' + ms; 13 | }; 14 | -------------------------------------------------------------------------------- /html-report/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = function() { 4 | return { 5 | entry: './src/index.js', 6 | output: { 7 | path: path.join(__dirname, 'build'), 8 | filename: 'app.js' 9 | }, 10 | resolve: { 11 | extensions: ['.js', '.json'] 12 | }, 13 | module: { 14 | rules: [ 15 | { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ } 16 | ] 17 | } 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /ci/docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # See https://denibertovic.com/posts/handling-permissions-with-docker-volumes/ 5 | 6 | # Add local user. 7 | # Either use the LOCAL_USER_ID if passed in at runtime or fallback to 9001. 8 | USER_ID=${LOCAL_USER_ID:-9001} 9 | 10 | echo "Starting with UID : $USER_ID" 11 | groupadd --gid $USER_ID build_user 12 | useradd --shell /bin/bash --uid $USER_ID --gid $USER_ID --comment "User for container" --create-home build_user 13 | 14 | # Run original docker run command as build_user. 15 | sudo --set-home --preserve-env -u build_user "$@" 16 | -------------------------------------------------------------------------------- /html-report/styles/helpers/_mixins.scss: -------------------------------------------------------------------------------- 1 | // Mixins 2 | 3 | @define-mixin clearfix { 4 | &:before, &:after { 5 | content: ''; 6 | display: table; 7 | } 8 | 9 | &:after { 10 | clear: both; 11 | } 12 | } 13 | 14 | @define-mixin with-arrow { 15 | &:before { 16 | content: ''; 17 | display: block; 18 | position: absolute; 19 | top: 8px; 20 | right: 13px; 21 | z-index: 4; 22 | width: 0; 23 | height: 0; 24 | border-top: 4px solid #0f375a; 25 | border-left: 4px solid transparent; 26 | border-right: 4px solid transparent; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /html-report/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'postcss-scss', 3 | plugins: [ 4 | require('postcss-import')({ path: ['src'] }), 5 | require('postcss-sass-colors'), 6 | require('postcss-mixins'), 7 | require('precss'), 8 | require('postcss-inline-svg'), 9 | require('autoprefixer')({ 10 | browsers: [ 11 | '> 5%', 12 | 'last 2 versions', 13 | 'IE 11' 14 | ] 15 | }), 16 | require('cssnano')({ 17 | preset: ['default', { 18 | discardComments: { 19 | removeAll: true, 20 | }, 21 | }] 22 | }) 23 | ] 24 | } -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/test.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import org.jetbrains.spek.api.dsl.SpecBody 4 | import java.io.File 5 | 6 | inline fun fileFromJarResources(name: String) = File(C::class.java.classLoader.getResource(name).file) 7 | 8 | fun testFile(): File = createTempFile().apply { deleteOnExit() } 9 | 10 | fun SpecBody.perform(body: () -> Unit) = beforeEachTest(body) 11 | 12 | fun SpecBody.cleanup(body: () -> Unit) = afterEachTest(body) 13 | 14 | /** Make a Unix-formatted String compliant with current operating system's newline format */ 15 | fun normalizeLinefeed(str: String): String = str.replace("\n", System.getProperty("line.separator")); 16 | -------------------------------------------------------------------------------- /html-report/layout/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Composer 7 | 8 | 12 | 13 | 14 |
15 | 16 | ${log} 17 |
Generated with ❤️  by Juno at ${date}
18 | 19 | 20 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-output-app-crash.txt: -------------------------------------------------------------------------------- 1 | INSTRUMENTATION_STATUS: numtests=1 2 | INSTRUMENTATION_STATUS: stream= 3 | com.example.test.TestClass: 4 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 5 | INSTRUMENTATION_STATUS: test=crashTest 6 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass: 7 | INSTRUMENTATION_STATUS: current=1 8 | INSTRUMENTATION_STATUS_CODE: 1 9 | INSTRUMENTATION_RESULT: shortMsg=java.lang.NullPointerException 10 | INSTRUMENTATION_RESULT: longMsg=java.lang.NullPointerException: Attempt to invoke virtual method 'void java.util.logging.Logger.log(java.util.logging.Level, java.lang.String, java.lang.Throwable)' on a null object reference 11 | 12 | INSTRUMENTATION_CODE: 0 -------------------------------------------------------------------------------- /html-report/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Suite from './components/Suite' 3 | import SuitesList from './components/SuitesList' 4 | import TestItem from './components/TestItem' 5 | 6 | class App extends Component { 7 | renderComponent() { 8 | if (window.mainData) { 9 | return 10 | } 11 | if (window.test) { 12 | return 13 | } 14 | if (window.suite) { 15 | return 16 | } 17 | return null; 18 | } 19 | 20 | render() { 21 | return ( 22 |
23 |
24 | { this.renderComponent() } 25 | {/*"/suites/:suiteId/tests/:deviceId/:testId" />*/} 26 |
27 |
28 | ); 29 | } 30 | } 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-output-unable-to-find-instrumentation-info.txt: -------------------------------------------------------------------------------- 1 | INSTRUMENTATION_STATUS: id=ActivityManagerService 2 | INSTRUMENTATION_STATUS: Error=Unable to find instrumentation info for: ComponentInfo{com.composer.example/com.composer.example.ExampleAndroidJUnitRunner} 3 | INSTRUMENTATION_STATUS_CODE: -1 4 | android.util.AndroidException: INSTRUMENTATION_FAILED: com.composer.example/com.composer.example.ExampleAndroidJUnitRunner 5 | at com.android.commands.am.Am.runInstrument(Am.java:1121) 6 | at com.android.commands.am.Am.onRun(Am.java:374) 7 | at com.android.internal.os.BaseCommand.run(BaseCommand.java:47) 8 | at com.android.commands.am.Am.main(Am.java:103) 9 | at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method) 10 | at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:257) 11 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/html/HtmlDevice.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.composer.Device 4 | import com.google.gson.annotations.SerializedName 5 | import java.io.File 6 | 7 | data class HtmlDevice( 8 | 9 | @SerializedName("id") 10 | val id: String, 11 | 12 | @SerializedName("model") 13 | val model: String, 14 | 15 | @SerializedName("logcat_path") 16 | val logcatPath: String, 17 | 18 | @SerializedName("instrumentation_output_path") 19 | val instrumentationOutputPath: String 20 | ) 21 | 22 | fun Device.toHtmlDevice(htmlReportDir: File) = HtmlDevice( 23 | id = id, 24 | model = model, 25 | logcatPath = logcat.relativePathTo(htmlReportDir), 26 | instrumentationOutputPath = instrumentationOutput.relativePathTo(htmlReportDir) 27 | ) 28 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/html/HtmlDeviceSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.composer.Device 4 | import com.gojuno.composer.testFile 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.jetbrains.spek.api.Spek 7 | import org.jetbrains.spek.api.dsl.context 8 | import org.jetbrains.spek.api.dsl.it 9 | 10 | class HtmlDeviceSpec : Spek({ 11 | 12 | context("Device.toHtmlDevice") { 13 | 14 | val device = Device(id = "testDevice1", logcat = testFile(), instrumentationOutput = testFile(), model = "testModel1") 15 | 16 | val htmlDevice = device.toHtmlDevice(testFile().parentFile) 17 | 18 | it("converts Device to HtmlDevice") { 19 | assertThat(htmlDevice).isEqualTo(HtmlDevice( 20 | id = device.id, 21 | model = device.model, 22 | logcatPath = device.logcat.name, 23 | instrumentationOutputPath = device.instrumentationOutput.name 24 | )) 25 | } 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/Files.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import org.apache.commons.io.input.Tailer 4 | import org.apache.commons.io.input.TailerListener 5 | import rx.Emitter.BackpressureMode 6 | import rx.Observable 7 | import java.io.File 8 | import java.io.FileNotFoundException 9 | import java.lang.Exception 10 | 11 | fun tail(file: File): Observable = Observable.create( 12 | { emitter -> 13 | Tailer.create(file, object : TailerListener { 14 | override fun init(tailer: Tailer) = emitter.setCancellation { tailer.stop() } 15 | override fun handle(line: String) = emitter.onNext(line) 16 | override fun handle(e: Exception) = emitter.onError(e) 17 | override fun fileRotated() = emitter.onError(IllegalStateException("Output rotation detected $file")) 18 | override fun fileNotFound() = emitter.onError(FileNotFoundException("$file file was not found")) 19 | }) 20 | }, 21 | BackpressureMode.BUFFER 22 | ) 23 | -------------------------------------------------------------------------------- /html-report/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); 2 | const path = require('path'); 3 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | 5 | 6 | module.exports = function() { 7 | return { 8 | entry: './src/index.js', 9 | output: { 10 | path: path.join(__dirname, 'build'), 11 | filename: 'app.min.js' 12 | }, 13 | resolve: { 14 | extensions: ['.js', '.json'] 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | loader: 'babel-loader', 21 | exclude: /node_modules/ 22 | } 23 | ] 24 | }, 25 | plugins: [ 26 | new UglifyJSPlugin(), 27 | new CopyWebpackPlugin([ 28 | { 29 | from: 'layout/index.html', 30 | to: './' 31 | }, 32 | { 33 | from: 'layout/log-container.html', 34 | to: './' 35 | }, 36 | { 37 | from: 'layout/log-entry.html', 38 | to: './' 39 | } 40 | ]) 41 | ] 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/html/HtmlShortSuite.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.composer.Suite 4 | import com.google.gson.annotations.SerializedName 5 | import java.io.File 6 | import java.util.concurrent.TimeUnit.NANOSECONDS 7 | 8 | data class HtmlShortSuite( 9 | 10 | @SerializedName("id") 11 | val id: String, 12 | 13 | @SerializedName("passed_count") 14 | val passedCount: Int, 15 | 16 | @SerializedName("ignored_count") 17 | val ignoredCount: Int, 18 | 19 | @SerializedName("failed_count") 20 | val failedCount: Int, 21 | 22 | @SerializedName("duration_millis") 23 | val durationMillis: Long, 24 | 25 | @SerializedName("devices") 26 | val devices: List 27 | ) 28 | 29 | fun Suite.toHtmlShortSuite(id: String, htmlReportDir: File) = HtmlShortSuite( 30 | id = id, 31 | passedCount = passedCount, 32 | ignoredCount = ignoredCount, 33 | failedCount = failedCount, 34 | durationMillis = NANOSECONDS.toMillis(durationNanos), 35 | devices = devices.map { it.toHtmlDevice(htmlReportDir) } 36 | ) 37 | -------------------------------------------------------------------------------- /html-report/styles/helpers/_colors.scss: -------------------------------------------------------------------------------- 1 | // Color swatch 2 | $primary-blue: #0f375a; 3 | $primary-blue-darker: color(darken(#0f375a, 10%)); 4 | $primary-blue-lighter: color(lighten(#0f375a, 10%)); 5 | 6 | $primary-grey: #879bab; 7 | $primary-grey-darker: color(darken(#879bab, 10%)); 8 | $primary-grey-lighter: rgba(135, 155, 171, 0.6); 9 | $primary-grey-lighter-2: rgba(135, 155, 171, 0.4); 10 | 11 | $light-grey: #e2e6f4; 12 | $light-grey-darker: #D7DBE8; 13 | 14 | $electric: #6e8aff; 15 | $electric-darker: color(darken(#6e8aff, 10%)); 16 | $electric-lighter: color(lighten(#6e8aff, 15%)); 17 | $electric-lightest: rgba(110, 138, 255, 0.1); 18 | 19 | $purple: #44547f; 20 | $purple-darker: color(darken(#44547f, 10%)); 21 | $purple-lighter: color(lighten(#44547f, 15%)); 22 | 23 | $green: #3ec07f; 24 | $green-darker: #39B075; 25 | $green-lighter: color(lighten(#3ec07f, 10%)); 26 | 27 | $yellow: #f6bb42; 28 | $yellow-darker: color(darken(#f6bb42, 10%)); 29 | $yellow-lighter: color(lighten(#f6bb42, 10%)); 30 | 31 | $red: #ec5555; 32 | $red-darker: color(darken(#ec5555, 10%));; 33 | $red-lighter: color(lighten(#ec5555, 10%)); 34 | $system-red: #ffe0e0; 35 | 36 | $background: #f7f8fc; 37 | $zebra-background: rgba(135, 155, 171, 0.07); 38 | $white: #fff; 39 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/ApkSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.jetbrains.spek.api.Spek 5 | import org.jetbrains.spek.api.dsl.context 6 | import org.jetbrains.spek.api.dsl.it 7 | 8 | class ApkSpec : Spek({ 9 | 10 | context("parse package from apk") { 11 | 12 | val testApkPath by memoized { fileFromJarResources("instrumentation-test.apk").absolutePath } 13 | 14 | it("parses test runner correctly") { 15 | assertThat(parseTestRunner(testApkPath)).isEqualTo(TestRunner.Valid("android.support.test.runner.AndroidJUnitRunner")) 16 | } 17 | 18 | it("parses test package correctly") { 19 | assertThat(parseTestPackage(testApkPath)).isEqualTo(TestPackage.Valid("test.test.myapplication.test")) 20 | } 21 | 22 | it("parses tests list correctly") { 23 | assertThat(parseTests(testApkPath)).isEqualTo(listOf( 24 | TestMethod("test.test.myapplication.ExampleInstrumentedTest#useAppContext", 25 | listOf("dalvik.annotation.Throws", "org.junit.Test", "org.junit.runner.RunWith") 26 | ) 27 | )) 28 | } 29 | } 30 | }) -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/html/HtmlShortTest.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class HtmlShortTest( 6 | 7 | @SerializedName("id") 8 | val id: String, 9 | 10 | @SerializedName("package_name") 11 | val packageName: String, 12 | 13 | @SerializedName("class_name") 14 | val className: String, 15 | 16 | @SerializedName("name") 17 | val name: String, 18 | 19 | @SerializedName("duration_millis") 20 | val durationMillis: Long, 21 | 22 | @SerializedName("status") 23 | val status: HtmlFullTest.Status, 24 | 25 | @SerializedName("deviceId") 26 | val deviceId: String, 27 | 28 | @SerializedName("deviceModel") 29 | val deviceModel: String, 30 | 31 | @SerializedName("properties") 32 | val properties: Map 33 | ) 34 | 35 | fun HtmlFullTest.toHtmlShortTest() = HtmlShortTest( 36 | id = id, 37 | packageName = packageName, 38 | className = className, 39 | name = name, 40 | durationMillis = durationMillis, 41 | status = status, 42 | deviceId = deviceId, 43 | deviceModel = deviceModel, 44 | properties = properties 45 | ) 46 | -------------------------------------------------------------------------------- /html-report/styles/_base.scss: -------------------------------------------------------------------------------- 1 | /* Reset */ 2 | html,body,div,span,applet,object,iframe, 3 | h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr, 4 | acronym,address,big,cite,code,del,dfn,em, 5 | font,img,ins,kbd,q,s,samp,small,strike, 6 | strong,sub,sup,tt,var,dl,dt,dd,ol,ul, 7 | li,fieldset,form,label,legend,table, 8 | caption,tbody,tfoot,thead,tr,th,td,hr { 9 | padding: 0; 10 | margin: 0; 11 | border: none; 12 | outline: none; 13 | font-family: inherit; 14 | } 15 | 16 | h1,h2,h3,h4,h5,h6 { 17 | font-weight: normal; 18 | } 19 | 20 | ul { 21 | list-style-type: none; 22 | list-style: none; 23 | } 24 | 25 | table { 26 | border-collapse: collapse; 27 | border-spacing: 0; 28 | } 29 | 30 | /* Global styles */ 31 | 32 | html { 33 | font: $font-normal/$line-height-normal $helvetica; 34 | } 35 | 36 | html, body { 37 | text-size-adjust: 100%; 38 | box-sizing: border-box; 39 | } 40 | 41 | html, body, .page-content { 42 | height: 100%; 43 | } 44 | 45 | body { 46 | background: $background; 47 | color: $primary-blue; 48 | -webkit-font-smoothing: antialiased; 49 | -moz-osx-font-smoothing: grayscale; 50 | } 51 | 52 | a { 53 | color: $electric; 54 | text-decoration: none; 55 | line-height: inherit; 56 | cursor: pointer; 57 | transition: color $short-transition; 58 | 59 | &:hover, &:active { 60 | color: $electric; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullSuite.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.composer.Suite 4 | import com.google.gson.annotations.SerializedName 5 | import java.io.File 6 | import java.util.concurrent.TimeUnit.NANOSECONDS 7 | 8 | data class HtmlFullSuite( 9 | 10 | @SerializedName("id") 11 | val id: String, 12 | 13 | @SerializedName("tests") 14 | val tests: List, 15 | 16 | @SerializedName("passed_count") 17 | val passedCount: Int, 18 | 19 | @SerializedName("ignored_count") 20 | val ignoredCount: Int, 21 | 22 | @SerializedName("failed_count") 23 | val failedCount: Int, 24 | 25 | @SerializedName("duration_millis") 26 | val durationMillis: Long, 27 | 28 | @SerializedName("devices") 29 | val devices: List 30 | ) 31 | 32 | fun Suite.toHtmlFullSuite(id: String, htmlReportDir: File) = HtmlFullSuite( 33 | id = id, 34 | tests = tests.map { it.toHtmlFullTest(suiteId = id, htmlReportDir = htmlReportDir).toHtmlShortTest() }, 35 | passedCount = passedCount, 36 | ignoredCount = ignoredCount, 37 | failedCount = failedCount, 38 | durationMillis = NANOSECONDS.toMillis(durationNanos), 39 | devices = devices.map { it.toHtmlDevice(htmlReportDir) } 40 | ) 41 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-output-ignored-test.txt: -------------------------------------------------------------------------------- 1 | INSTRUMENTATION_STATUS: numtests=2 2 | INSTRUMENTATION_STATUS: stream= 3 | com.example.test.TestClass: 4 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 5 | INSTRUMENTATION_STATUS: test=test1 6 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 7 | INSTRUMENTATION_STATUS: current=1 8 | INSTRUMENTATION_STATUS_CODE: 1 9 | INSTRUMENTATION_STATUS: numtests=2 10 | INSTRUMENTATION_STATUS: stream=. 11 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 12 | INSTRUMENTATION_STATUS: test=test1 13 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 14 | INSTRUMENTATION_STATUS: current=1 15 | INSTRUMENTATION_STATUS_CODE: 0 16 | INSTRUMENTATION_STATUS: numtests=2 17 | INSTRUMENTATION_STATUS: stream= 18 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 19 | INSTRUMENTATION_STATUS: test=test2 20 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 21 | INSTRUMENTATION_STATUS: current=2 22 | INSTRUMENTATION_STATUS_CODE: 1 23 | INSTRUMENTATION_STATUS: numtests=2 24 | INSTRUMENTATION_STATUS: stream= 25 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 26 | INSTRUMENTATION_STATUS: test=test2 27 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 28 | INSTRUMENTATION_STATUS: current=2 29 | INSTRUMENTATION_STATUS_CODE: -3 30 | INSTRUMENTATION_RESULT: stream= 31 | 32 | Time: 10.073 33 | 34 | OK (2 tests) 35 | 36 | 37 | INSTRUMENTATION_CODE: -1 38 | -------------------------------------------------------------------------------- /ci/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:8u121-jdk 2 | 3 | MAINTAINER Juno Composer Team 4 | 5 | RUN apt-get update && \ 6 | apt-get --assume-yes install git curl sudo && \ 7 | curl -sL https://deb.nodesource.com/setup_7.x | bash - && apt-get install --assume-yes nodejs 8 | 9 | # `aapt` Android SDK build-tool is needed 10 | # v26.0.1, https://issuetracker.google.com/issues/64292349 11 | ENV ANDROID_SDK_URL "https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip" 12 | ENV ANDROID_SDK_FILE_NAME "android-sdk.zip" 13 | 14 | ENV ANDROID_HOME /opt/android-sdk-linux 15 | ENV PATH ${PATH}:${ANDROID_HOME}/tools:${ANDROID_HOME}/tools/bin:${ANDROID_HOME}/platform-tools 16 | 17 | RUN \ 18 | mkdir -p $ANDROID_HOME && \ 19 | curl $ANDROID_SDK_URL --progress-bar --location --output $ANDROID_SDK_FILE_NAME && \ 20 | unzip $ANDROID_SDK_FILE_NAME -d $ANDROID_HOME && \ 21 | rm $ANDROID_SDK_FILE_NAME 22 | 23 | # Download required parts of Android SDK (separate from Android SDK layer). 24 | 25 | ENV ANDROID_SDK_COMPONENTS_REVISION 2017-10-25-15-22 26 | ENV ANDROID_SDK_INSTALL_COMPONENT "echo \"y\" | \"$ANDROID_HOME\"/tools/bin/sdkmanager --verbose" 27 | 28 | RUN \ 29 | echo "Android SDK packages revision $ANDROID_SDK_COMPONENTS_REVISION" && \ 30 | eval $ANDROID_SDK_INSTALL_COMPONENT '"build-tools;27.0.3"' 31 | 32 | # Entrypoint script will allow us run as non-root in the container. 33 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 34 | RUN chmod +x /usr/local/bin/entrypoint.sh 35 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 36 | -------------------------------------------------------------------------------- /html-report/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "juno-composer", 3 | "version": "1.0.0", 4 | "description": "Juno Composer HTML report", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "webpack --env=dev --progress --watch", 8 | "build": "npm run postcss-build && webpack --env=prod --progress", 9 | "postcss-dev": "postcss ./styles/index.scss -o=./build/app.min.css -w", 10 | "postcss-build": "postcss ./styles/index.scss -o=./build/app.min.css" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "babel-core": "^6.24.1", 16 | "babel-loader": "^7.0.0", 17 | "babel-preset-es2015": "^6.24.1", 18 | "babel-preset-react": "^6.24.1", 19 | "classnames": "^2.2.5", 20 | "copy-webpack-plugin": "^4.0.1", 21 | "cssnano": "^3.10.0", 22 | "elasticlunr": "^0.9.5", 23 | "extract-text-webpack-plugin": "^2.1.0", 24 | "postcss": "^5.0.19", 25 | "postcss-cli": "^4.0.0", 26 | "postcss-import": "^8.0.2", 27 | "postcss-inline-svg": "^1.3.2", 28 | "postcss-mixins": "^6.0.0", 29 | "postcss-reporter": "^1.3.3", 30 | "postcss-sass-colors": "0.0.2", 31 | "postcss-sass-mixins": "^0.3.0", 32 | "postcss-scss": "^0.1.7", 33 | "precss": "^1.4.0", 34 | "randomcolor": "^0.5.3", 35 | "react": "^15.5.4", 36 | "react-dom": "^15.5.4", 37 | "react-router-dom": "^4.1.1", 38 | "uglifyjs-webpack-plugin": "^0.4.3", 39 | "webpack": "^2.6.1" 40 | }, 41 | "devDependencies": { 42 | "babel-plugin-transform-class-properties": "^6.24.1", 43 | "babel-preset-latest": "^6.24.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ci/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # You can run it from any directory. 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | PROJECT_DIR="$DIR/.." 7 | 8 | pushd "$PROJECT_DIR" 9 | 10 | # Files created in mounted volume by container should have same owner as host machine user to prevent chmod problems. 11 | USER_ID=`id -u $USER` 12 | 13 | if [ "$USER_ID" == "0" ]; then 14 | echo "Warning: running as r00t." 15 | fi 16 | 17 | docker build -t composer:latest ci/docker 18 | 19 | BUILD_COMMAND="set -e && " 20 | 21 | BUILD_COMMAND+="echo 'Java version:' && java -version && " 22 | BUILD_COMMAND+="echo 'Node.js version:' && node --version && " 23 | BUILD_COMMAND+="echo 'npm vesion:' && npm --version && " 24 | 25 | # Build HTML report app. 26 | BUILD_COMMAND+="echo 'Building HTML report app...' && " 27 | BUILD_COMMAND+="cd /opt/project/html-report && " 28 | BUILD_COMMAND+="rm -rf node_modules && " 29 | BUILD_COMMAND+="npm install && " 30 | BUILD_COMMAND+="npm run build && " 31 | BUILD_COMMAND+="cd /opt/project && " 32 | BUILD_COMMAND+="rm -rf composer/src/main/resources/html-report/ && " 33 | BUILD_COMMAND+="mkdir -p composer/src/main/resources/html-report/ && " 34 | BUILD_COMMAND+="cp -R html-report/build/* composer/src/main/resources/html-report/ && " 35 | 36 | # Build Composer. 37 | BUILD_COMMAND+="echo 'Building Composer...' && " 38 | BUILD_COMMAND+="/opt/project/gradlew " 39 | BUILD_COMMAND+="--no-daemon --info --stacktrace " 40 | BUILD_COMMAND+="clean build " 41 | 42 | if [ "$PUBLISH" == "true" ]; then 43 | BUILD_COMMAND+="bintrayUpload " 44 | fi 45 | 46 | BUILD_COMMAND+="--project-dir /opt/project" 47 | 48 | docker run \ 49 | --env LOCAL_USER_ID="$USER_ID" \ 50 | --env BINTRAY_USER="$BINTRAY_USER" \ 51 | --env BINTRAY_API_KEY="$BINTRAY_API_KEY" \ 52 | --env BINTRAY_GPG_PASSPHRASE="$BINTRAY_GPG_PASSPHRASE" \ 53 | --volume `"pwd"`:/opt/project \ 54 | --rm \ 55 | composer:latest \ 56 | bash -c "$BUILD_COMMAND" 57 | 58 | popd 59 | -------------------------------------------------------------------------------- /dependencies.gradle: -------------------------------------------------------------------------------- 1 | ext.versions = [ 2 | kotlin : '1.1.1', 3 | 4 | rxJava : '1.3.0', 5 | jCommander : '1.71', 6 | commander : '0.1.7', 7 | apacheCommonsIo : '2.5', 8 | apacheCommonsLang: '3.5', 9 | gson : '2.8.0', 10 | dexParser : '1.1.0', 11 | 12 | junit : '4.12', 13 | junitPlatform : '1.0.0-M4', 14 | spek : '1.1.2', 15 | assertJ : '3.5.2', 16 | ] 17 | 18 | ext.libraries = [ 19 | kotlinStd : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin", 20 | kotlinRuntime : "org.jetbrains.kotlin:kotlin-runtime:$versions.kotlin", 21 | kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin", 22 | 23 | rxJava : "io.reactivex:rxjava:$versions.rxJava", 24 | jCommander : "com.beust:jcommander:$versions.jCommander", 25 | commanderOs : "com.gojuno.commander:os:$versions.commander", 26 | commanderAndroid : "com.gojuno.commander:android:$versions.commander", 27 | apacheCommonsIo : "commons-io:commons-io:$versions.apacheCommonsIo", 28 | apacheCommonsLang : "org.apache.commons:commons-lang3:$versions.apacheCommonsLang", 29 | gson : "com.google.code.gson:gson:$versions.gson", 30 | dexParser : "com.linkedin.dextestparser:parser:$versions.dexParser", 31 | 32 | junit : "junit:junit:$versions.junit", 33 | spek : "org.jetbrains.spek:spek-api:$versions.spek", 34 | junitPlatformRunner : "org.junit.platform:junit-platform-launcher:$versions.junitPlatform", 35 | spekJunitPlatformEngine: "org.jetbrains.spek:spek-junit-platform-engine:$versions.spek", 36 | assertJ : "org.assertj:assertj-core:$versions.assertJ", 37 | ] 38 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-unordered-output.txt: -------------------------------------------------------------------------------- 1 | INSTRUMENTATION_STATUS: numtests=3 2 | INSTRUMENTATION_STATUS: stream= 3 | com.example.test.TestClass: 4 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 5 | INSTRUMENTATION_STATUS: test=test1 6 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 7 | INSTRUMENTATION_STATUS: current=1 8 | INSTRUMENTATION_STATUS_CODE: 1 9 | INSTRUMENTATION_STATUS: numtests=3 10 | INSTRUMENTATION_STATUS: stream=. 11 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 12 | INSTRUMENTATION_STATUS: test=test1 13 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 14 | INSTRUMENTATION_STATUS: current=1 15 | INSTRUMENTATION_STATUS_CODE: 0 16 | INSTRUMENTATION_STATUS: numtests=3 17 | INSTRUMENTATION_STATUS: stream= 18 | com.example.test.TestClass: 19 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 20 | INSTRUMENTATION_STATUS: test=test2 21 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 22 | INSTRUMENTATION_STATUS: current=2 23 | INSTRUMENTATION_STATUS_CODE: 1 24 | INSTRUMENTATION_STATUS: numtests=3 25 | INSTRUMENTATION_STATUS: stream= 26 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 27 | INSTRUMENTATION_STATUS: test=test3 28 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 29 | INSTRUMENTATION_STATUS: current=3 30 | INSTRUMENTATION_STATUS_CODE: 1 31 | INSTRUMENTATION_STATUS: numtests=3 32 | INSTRUMENTATION_STATUS: stream=. 33 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 34 | INSTRUMENTATION_STATUS: test=test2 35 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 36 | INSTRUMENTATION_STATUS: current=2 37 | INSTRUMENTATION_STATUS_CODE: 0 38 | INSTRUMENTATION_STATUS: numtests=3 39 | INSTRUMENTATION_STATUS: stream=. 40 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 41 | INSTRUMENTATION_STATUS: test=test3 42 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 43 | INSTRUMENTATION_STATUS: current=3 44 | INSTRUMENTATION_STATUS_CODE: 0 45 | 46 | Time: 0 47 | 48 | OK (3 tests) 49 | 50 | 51 | INSTRUMENTATION_CODE: -1 52 | 53 | Script done on Fri Mar 31 15:28:27 2017 54 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/html/HtmlShortTestSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.jetbrains.spek.api.Spek 5 | import org.jetbrains.spek.api.dsl.context 6 | import org.jetbrains.spek.api.dsl.it 7 | 8 | class HtmlShortTestSpec : Spek({ 9 | 10 | context("HtmlFullTest.toHtmlShortTest") { 11 | 12 | val htmlFullTest by memoized { 13 | HtmlFullTest( 14 | suiteId = "testSuite", 15 | packageName = "com.gojuno.example", 16 | className = "TestClass", 17 | name = "test1", 18 | deviceModel = "test-device-model", 19 | status = HtmlFullTest.Status.Passed, 20 | durationMillis = 1234, 21 | stacktrace = null, 22 | logcatPath = "testLogcatPath", 23 | filePaths = listOf("testFilePath1", "testFilePath2"), 24 | screenshots = listOf(HtmlFullTest.Screenshot(path = "testScreenshotPath1", title = "testScreenshot1"), HtmlFullTest.Screenshot(path = "testScreenshotPath2", title = "testScreenshot2")), 25 | deviceId = "test-device-id", 26 | properties = mapOf("key1" to "value1", "key2" to "value2") 27 | ) 28 | } 29 | 30 | val htmlShortTest by memoized { htmlFullTest.toHtmlShortTest() } 31 | 32 | it("converts HtmlFullTest to HtmlShortTest") { 33 | assertThat(htmlShortTest).isEqualTo(HtmlShortTest( 34 | id = htmlFullTest.id, 35 | packageName = "com.gojuno.example", 36 | className = "TestClass", 37 | name = "test1", 38 | status = HtmlFullTest.Status.Passed, 39 | durationMillis = 1234, 40 | deviceId = "test-device-id", 41 | deviceModel = "test-device-model", 42 | properties = mapOf("key1" to "value1", "key2" to "value2") 43 | )) 44 | } 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullTestSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.commander.android.AdbDevice 4 | import com.gojuno.composer.AdbDeviceTest 5 | import com.gojuno.composer.testFile 6 | import org.assertj.core.api.Assertions.assertThat 7 | import org.jetbrains.spek.api.Spek 8 | import org.jetbrains.spek.api.dsl.context 9 | import org.jetbrains.spek.api.dsl.it 10 | import java.util.concurrent.TimeUnit.NANOSECONDS 11 | 12 | class HtmlFullTestSpec : Spek({ 13 | 14 | context("AdbDeviceTest.toHtmlTest") { 15 | 16 | val adbDeviceTest = AdbDeviceTest( 17 | adbDevice = AdbDevice(id = "testDevice", online = true, model = "testModel"), 18 | className = "com.gojuno.example.TestClass", 19 | testName = "test1", 20 | status = AdbDeviceTest.Status.Passed, 21 | durationNanos = 23000, 22 | logcat = testFile(), 23 | files = listOf(testFile(), testFile()), 24 | screenshots = listOf(testFile(), testFile()) 25 | ) 26 | 27 | val htmlTest = adbDeviceTest.toHtmlFullTest(suiteId = "testSuite", htmlReportDir = testFile().parentFile) 28 | 29 | it("converts AdbDeviceTest to HtmlFullTest") { 30 | assertThat(htmlTest).isEqualTo(HtmlFullTest( 31 | suiteId = "testSuite", 32 | packageName = "com.gojuno.example", 33 | className = "TestClass", 34 | name = adbDeviceTest.testName, 35 | deviceModel = "testModel", 36 | status = HtmlFullTest.Status.Passed, 37 | durationMillis = NANOSECONDS.toMillis(adbDeviceTest.durationNanos), 38 | stacktrace = null, 39 | logcatPath = adbDeviceTest.logcat.name, 40 | filePaths = adbDeviceTest.files.map { it.name }, 41 | screenshots = adbDeviceTest.screenshots.map { HtmlFullTest.Screenshot(path = it.name, title = it.nameWithoutExtension) }, 42 | deviceId = adbDeviceTest.adbDevice.id, 43 | properties = emptyMap() 44 | )) 45 | } 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/LogLineParserSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.jetbrains.spek.api.Spek 5 | import org.jetbrains.spek.api.dsl.context 6 | import org.jetbrains.spek.api.dsl.it 7 | 8 | class LogLineParserSpec : Spek({ 9 | 10 | context("parse TestRunner log line with long prefix") { 11 | 12 | context("parse started log") { 13 | val args by memoized { 14 | "04-06 00:25:49.747 28632 28650 I TestRunner: started: someTestMethod(com.example.SampleClass)".parseTestClassAndName() 15 | } 16 | 17 | it("extracts test class and method") { 18 | assertThat(args).isEqualTo("com.example.SampleClass" to "someTestMethod") 19 | } 20 | } 21 | 22 | context("parse finished log") { 23 | val args by memoized { 24 | "04-06 00:25:49.747 28632 28650 I TestRunner: finished: someTestMethod(com.example.SampleClass)".parseTestClassAndName() 25 | } 26 | 27 | it("extracts test class and method") { 28 | assertThat(args).isEqualTo("com.example.SampleClass" to "someTestMethod") 29 | } 30 | } 31 | } 32 | 33 | context("parse TestRunner log line with short prefix") { 34 | 35 | context("parse started log") { 36 | 37 | val args by memoized { 38 | "I/TestRunner( 123): started: someTestMethod(com.example.SampleClass)".parseTestClassAndName() 39 | } 40 | 41 | it("extracts test class and method") { 42 | assertThat(args).isEqualTo("com.example.SampleClass" to "someTestMethod") 43 | } 44 | } 45 | 46 | context("parse finished log") { 47 | 48 | val args by memoized { 49 | "I/TestRunner( 123): finished: someTestMethod(com.example.SampleClass)".parseTestClassAndName() 50 | } 51 | 52 | it("extracts test class and method") { 53 | assertThat(args).isEqualTo("com.example.SampleClass" to "someTestMethod") 54 | } 55 | } 56 | } 57 | 58 | context("parse non TestRunner started/finished logs") { 59 | 60 | it("does not parse empty log") { 61 | assertThat("".parseTestClassAndName()).isNull() 62 | } 63 | 64 | it("does not parse TestRunner logs without started/finished") { 65 | assertThat("I/TestRunner( 123):".parseTestClassAndName()).isNull() 66 | assertThat("04-06 00:25:49.747 28632 28650 I TestRunner:".parseTestClassAndName()).isNull() 67 | } 68 | } 69 | }) -------------------------------------------------------------------------------- /spec/REPORT.md: -------------------------------------------------------------------------------- 1 | # Terms 2 | 3 | * Test is a single functionality check. 4 | * Properties: 5 | * package name; 6 | * class name; 7 | * name; 8 | * duration; 9 | * log; 10 | * stacktrace (optional); 11 | * completion status (passed | failed | ignored); 12 | * device name; 13 | * list of properties — property is a key-value pair; 14 | * list of files; 15 | * list of screenshots. 16 | * Test suite is a combination of all tests available. 17 | * Properties: 18 | * list of tests; 19 | * success tests count; 20 | * failure tests count; 21 | * ignored tests count; 22 | * list of devices: 23 | * device id; 24 | * instrumentation output; 25 | * logcat; 26 | * duration. 27 | * Test suite can be run different ways. 28 | * Single device — just a single run of all tests in a test suite. 29 | * Multiple devices. 30 | * Sharding — all tests in a test suite are being split evenly between all devices. 31 | * Duplicating — all tests in a test suite are being run the same way on each device available. 32 | 33 | # Report Pages 34 | 35 | ## Device List 36 | 37 | This page should be skipped if sharding was used to run the test suite. 38 | 39 | Contains a list of items with following properties: 40 | 41 | * device name; 42 | * test run duration; 43 | * instrumentation output; 44 | * logcat output; 45 | * test suite success tests count; 46 | * test suite failure tests count; 47 | * test suite ignored tests count. 48 | 49 | Available actions: 50 | 51 | * click on the list item opens the Test Suite page. 52 | 53 | ## Test Suite 54 | 55 | Contains following blocks from top to bottom. 56 | 57 | * Summary with following properties: 58 | * success tests count; 59 | * failure tests count; 60 | * ignored tests count; 61 | * duration. 62 | * Search input. 63 | * List of tests with following properties: 64 | * package name; 65 | * class name; 66 | * name; 67 | * device name. 68 | 69 | Available actions: 70 | 71 | * click on the list item opens the Test page; 72 | * changing search input contents changes list of tests content. 73 | 74 | ## Test 75 | 76 | Contains following blocks from top to bottom. 77 | 78 | * Summary: 79 | * package name; 80 | * class name; 81 | * name; 82 | * device name; 83 | * duration. 84 | * Properties: 85 | * list of key-value pairs. 86 | * Files: 87 | * list of file links. 88 | * Screenshots: 89 | * list of screenshot images. 90 | * Log. 91 | 92 | Available actions: 93 | 94 | * click on a file starts its downloading; 95 | * click on a screenshot opens in a new tab full-size. 96 | -------------------------------------------------------------------------------- /html-report/styles/helpers/_fonts.scss: -------------------------------------------------------------------------------- 1 | // Fonts 2 | 3 | // Variables 4 | $helvetica: 'Helvetica Neue', Helvetica, Arial, sans-serif; 5 | $font-small: 12px; 6 | $font-normal: 14px; 7 | $font-medium: 16px; 8 | $font-large: 16px; 9 | $font-small-wide-screen: 13px; 10 | $font-normal-wide-screen: 16px; 11 | $font-large-wide-screen: 18px; 12 | $line-height-normal: 1.3; 13 | $line-height-medium: 1.25; 14 | $line-height-small: 1.12; 15 | 16 | // NEW 17 | @define-mixin text-title { 18 | font-size: $font-large; 19 | line-height: $line-height-normal; 20 | font-weight: 500; 21 | text-transform: uppercase; 22 | } 23 | 24 | @define-mixin text-sub-title { 25 | font-size: $font-large; 26 | line-height: $line-height-small; 27 | font-weight: 500; 28 | } 29 | 30 | @define-mixin text-sub-title-light { 31 | font-size: $font-large; 32 | line-height: $line-height-small; 33 | font-weight: 500; 34 | color: $primary-grey; 35 | } 36 | 37 | @define-mixin text-regular { 38 | font-size: $font-normal; 39 | line-height: $line-height-normal; 40 | } 41 | 42 | @define-mixin text-note { 43 | font-size: $font-small; 44 | line-height: $line-height-normal; 45 | color: $primary-grey; 46 | } 47 | 48 | @define-mixin text-regular-grey { 49 | font-size: $font-normal; 50 | line-height: $line-height-normal; 51 | color: $primary-grey; 52 | } 53 | 54 | @define-mixin text-important { 55 | color: $red; 56 | } 57 | 58 | @define-mixin text-grey { 59 | color: $primary-grey; 60 | } 61 | 62 | @define-mixin text-error-text { 63 | font-size: $font-small; 64 | line-height: $line-height-small; 65 | font-weight: bold; 66 | color: $red; 67 | } 68 | 69 | .text-title { 70 | @mixin text-title; 71 | } 72 | 73 | .text-sub-title { 74 | @mixin text-sub-title; 75 | } 76 | 77 | .text-sub-title-light { 78 | @mixin text-sub-title-light; 79 | } 80 | 81 | .text-regular { 82 | @mixin text-regular; 83 | } 84 | 85 | .text-regular-grey { 86 | @mixin text-regular-grey; 87 | } 88 | 89 | .text-note { 90 | @mixin text-note; 91 | } 92 | 93 | .text-important { 94 | @mixin text-important; 95 | } 96 | 97 | .text-grey { 98 | @mixin text-grey; 99 | } 100 | 101 | .text-error-text { 102 | @mixin text-error-text; 103 | } 104 | 105 | @media $large-screen { 106 | .text-title { 107 | font-size: $font-large-wide-screen; 108 | } 109 | 110 | .text-sub-title { 111 | font-size: $font-large-wide-screen; 112 | } 113 | 114 | .text-sub-title-light { 115 | font-size: $font-large-wide-screen; 116 | } 117 | 118 | .text-regular { 119 | font-size: $font-normal-wide-screen; 120 | } 121 | 122 | .text-note { 123 | font-size: $font-small-wide-screen; 124 | } 125 | 126 | .text-error-text { 127 | font-size: $font-small-wide-screen; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /html-report/src/components/Suite.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import cx from 'classnames'; 3 | import randomColor from 'randomcolor'; 4 | import convertTime from './../utils/convertTime'; 5 | import paths from './../utils/paths'; 6 | import SearchBar from './SearchBar'; 7 | 8 | export default class Suite extends Component { 9 | state = { 10 | colors: null, 11 | tests: window.suite.tests 12 | }; 13 | 14 | componentWillMount() { 15 | this.setDevicesLabelColors(); 16 | document.title = `Suite ${window.suite.id}`; 17 | } 18 | 19 | setDevicesLabelColors() { 20 | const generatedColors = randomColor({ 21 | count: window.suite.devices.length, 22 | luminosity: 'bright' 23 | }); 24 | let colors = {}; 25 | window.suite.devices.map((item, i) => { 26 | colors[item.id] = generatedColors[i]; 27 | }); 28 | this.setState({ colors }); 29 | } 30 | 31 | getSearchResults(results) { 32 | this.setState({ tests: results }); 33 | } 34 | 35 | render() { 36 | const data = window.suite; 37 | return ( 38 |
39 |
Suites list/ Suite {data.id}
40 | 41 | this.getSearchResults(results) } /> 42 | 43 | 72 |
73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /html-report/src/components/SuitesList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import convertTime from './../utils/convertTime'; 3 | import paths from './../utils/paths'; 4 | 5 | export default class SuitesList extends Component { 6 | render() { 7 | return ( 8 |
9 |
Suites list
10 | 11 | { window.mainData.suites.map((suite) => { 12 | return ( 13 |
14 | 15 | Suite { suite.id } 16 | 17 |
18 |
19 |
Passed
20 |
{ suite.passed_count }
21 |
22 |
23 |
Failed
24 |
{ suite.failed_count }
25 |
26 |
27 |
Ignored
28 |
{ suite.ignored_count }
29 |
30 |
31 |
Duration
32 |
{ convertTime(suite.duration_millis) }
33 |
34 |
35 |
Devices
36 |
{ suite.devices.length }
37 |
38 |
39 | 55 |
56 | ) 57 | } 58 | )} 59 |
60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/Apk.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import com.gojuno.commander.android.aapt 4 | import com.gojuno.commander.os.Notification 5 | import com.gojuno.commander.os.process 6 | import com.linkedin.dex.parser.DexParser 7 | 8 | sealed class TestPackage { 9 | data class Valid(val value: String) : TestPackage() 10 | data class ParseError(val error: String) : TestPackage() 11 | } 12 | 13 | sealed class TestRunner { 14 | data class Valid(val value: String) : TestRunner() 15 | data class ParseError(val error: String) : TestRunner() 16 | } 17 | 18 | fun parseTestPackage(testApkPath: String): TestPackage = 19 | process( 20 | commandAndArgs = listOf( 21 | aapt, "dump", "badging", testApkPath 22 | ), 23 | unbufferedOutput = true 24 | ) 25 | .ofType(Notification.Exit::class.java) 26 | .map { (output) -> 27 | output 28 | .readText() 29 | .split(System.lineSeparator()) 30 | // output format `package: name='$testPackage' versionCode='' versionName='' platformBuildVersionName='xxx'` 31 | .firstOrNull { it.contains("package") } 32 | ?.split(" ") 33 | ?.firstOrNull { it.startsWith("name=") } 34 | ?.split("'") 35 | ?.getOrNull(1) 36 | ?.let(TestPackage::Valid) 37 | ?: TestPackage.ParseError("Cannot parse test package from `aapt dump badging \$APK` output.") 38 | } 39 | .toSingle() 40 | .toBlocking() 41 | .value() 42 | 43 | fun parseTestRunner(testApkPath: String): TestRunner = 44 | process( 45 | commandAndArgs = listOf( 46 | aapt, "dump", "xmltree", testApkPath, "AndroidManifest.xml" 47 | ), 48 | unbufferedOutput = true 49 | ) 50 | .ofType(Notification.Exit::class.java) 51 | .map { (output) -> 52 | output 53 | .readText() 54 | .split(System.lineSeparator()) 55 | .dropWhile { !it.contains("instrumentation") } 56 | .firstOrNull { it.contains("android:name") } 57 | // output format : `A: android:name(0x01010003)="$testRunner" (Raw: "$testRunner")` 58 | ?.split("\"") 59 | ?.getOrNull(1) 60 | ?.let(TestRunner::Valid) 61 | ?: TestRunner.ParseError("Cannot parse test runner from `aapt dump xmltree \$TEST_APK AndroidManifest.xml` output.") 62 | } 63 | .toSingle() 64 | .toBlocking() 65 | .value() 66 | 67 | fun parseTests(testApkPath: String): List = 68 | DexParser.findTestMethods(testApkPath).map { TestMethod(it.testName, it.annotationNames) } 69 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-output-assumption-violation.txt: -------------------------------------------------------------------------------- 1 | INSTRUMENTATION_STATUS: numtests=1 2 | INSTRUMENTATION_STATUS: stream= 3 | com.example.test.TestClass: 4 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 5 | INSTRUMENTATION_STATUS: test=violated 6 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 7 | INSTRUMENTATION_STATUS: current=1 8 | INSTRUMENTATION_STATUS_CODE: 1 9 | INSTRUMENTATION_STATUS: numtests=1 10 | INSTRUMENTATION_STATUS: stream= 11 | com.example.test.TestClass: 12 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 13 | INSTRUMENTATION_STATUS: test=violated 14 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 15 | INSTRUMENTATION_STATUS: stack=org.junit.AssumptionViolatedException: got: "foo", expected: is "bar" 16 | at org.junit.Assume.assumeThat(Assume.java:95) 17 | at com.example.test.TestClass.violated(TestClass.java:22) 18 | at java.lang.reflect.Method.invoke(Native Method) 19 | at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50) 20 | at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) 21 | at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) 22 | at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) 23 | at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) 24 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) 25 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) 26 | at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) 27 | at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) 28 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 29 | at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) 30 | at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) 31 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 32 | at org.junit.runners.Suite.runChild(Suite.java:128) 33 | at org.junit.runners.Suite.runChild(Suite.java:27) 34 | at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290) 35 | at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71) 36 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 37 | at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58) 38 | at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268) 39 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 40 | at org.junit.runner.JUnitCore.run(JUnitCore.java:137) 41 | at org.junit.runner.JUnitCore.run(JUnitCore.java:115) 42 | at android.support.test.internal.runner.TestExecutor.execute(TestExecutor.java:58) 43 | at android.support.test.runner.AndroidJUnitRunner.onStart(AndroidJUnitRunner.java:375) 44 | at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:2074) 45 | 46 | INSTRUMENTATION_STATUS: current=1 47 | INSTRUMENTATION_STATUS_CODE: -4 48 | INSTRUMENTATION_RESULT: stream= 49 | 50 | Time: ٠٫٠١٥ 51 | 52 | OK (1 test) 53 | 54 | 55 | INSTRUMENTATION_CODE: -1 56 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/html/HtmlFullSuiteSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.commander.android.AdbDevice 4 | import com.gojuno.composer.AdbDeviceTest 5 | import com.gojuno.composer.Device 6 | import com.gojuno.composer.Suite 7 | import com.gojuno.composer.testFile 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.jetbrains.spek.api.Spek 10 | import org.jetbrains.spek.api.dsl.context 11 | import org.jetbrains.spek.api.dsl.it 12 | import java.util.concurrent.TimeUnit.NANOSECONDS 13 | 14 | class HtmlFullSuiteSpec : Spek({ 15 | 16 | context("Suite.toHtmlFullSuite") { 17 | val suite = Suite( 18 | testPackage = "p", 19 | devices = listOf( 20 | Device(id = "device1", logcat = testFile(), instrumentationOutput = testFile(), model = "model1"), 21 | Device(id = "device2", logcat = testFile(), instrumentationOutput = testFile(), model = "model2") 22 | ), 23 | tests = listOf( 24 | AdbDeviceTest( 25 | adbDevice = AdbDevice(id = "device1", online = true), 26 | className = "c", 27 | testName = "t1", 28 | status = AdbDeviceTest.Status.Passed, 29 | durationNanos = 200000, 30 | logcat = testFile(), 31 | files = listOf(testFile(), testFile()), 32 | screenshots = listOf(testFile(), testFile()) 33 | ), 34 | AdbDeviceTest( 35 | adbDevice = AdbDevice(id = "device2", online = true), 36 | className = "c", 37 | testName = "t2", 38 | status = AdbDeviceTest.Status.Passed, 39 | durationNanos = 300000, 40 | logcat = testFile(), 41 | files = listOf(testFile(), testFile()), 42 | screenshots = listOf(testFile(), testFile()) 43 | ) 44 | ), 45 | passedCount = 2, 46 | ignoredCount = 0, 47 | failedCount = 0, 48 | durationNanos = 500000, 49 | timestampMillis = 123 50 | ) 51 | 52 | val htmlFullSuite = suite.toHtmlFullSuite(id = "testSuite", htmlReportDir = testFile()) 53 | 54 | it("converts Suite to HtmlFullSuite") { 55 | assertThat(htmlFullSuite).isEqualTo(HtmlFullSuite( 56 | id = "testSuite", 57 | passedCount = suite.passedCount, 58 | ignoredCount = suite.ignoredCount, 59 | failedCount = suite.failedCount, 60 | durationMillis = NANOSECONDS.toMillis(suite.durationNanos), 61 | devices = suite.devices.map { it.toHtmlDevice(htmlReportDir = testFile()) }, 62 | tests = suite.tests.map { it.toHtmlFullTest(suiteId = "testSuite", htmlReportDir = testFile()).toHtmlShortTest() } 63 | )) 64 | } 65 | } 66 | }) 67 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/html/HtmlShortSuiteSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.commander.android.AdbDevice 4 | import com.gojuno.composer.AdbDeviceTest 5 | import com.gojuno.composer.Device 6 | import com.gojuno.composer.Suite 7 | import com.gojuno.composer.testFile 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.jetbrains.spek.api.Spek 10 | import org.jetbrains.spek.api.dsl.context 11 | import org.jetbrains.spek.api.dsl.it 12 | import java.util.concurrent.TimeUnit.NANOSECONDS 13 | 14 | class HtmlShortSuiteSpec : Spek({ 15 | 16 | context("Suite.toHtmlShortSuite") { 17 | val suite by memoized { 18 | Suite( 19 | testPackage = "p", 20 | devices = listOf( 21 | Device(id = "device1", logcat = testFile(), instrumentationOutput = testFile(), model = "model1"), 22 | Device(id = "device2", logcat = testFile(), instrumentationOutput = testFile(), model = "model2") 23 | ), 24 | tests = listOf( 25 | AdbDeviceTest( 26 | adbDevice = AdbDevice(id = "device1", online = true), 27 | className = "c", 28 | testName = "t1", 29 | status = AdbDeviceTest.Status.Passed, 30 | durationNanos = 200000, 31 | logcat = testFile(), 32 | files = listOf(testFile(), testFile()), 33 | screenshots = listOf(testFile(), testFile()) 34 | ), 35 | AdbDeviceTest( 36 | adbDevice = AdbDevice(id = "device2", online = true), 37 | className = "c", 38 | testName = "t2", 39 | status = AdbDeviceTest.Status.Passed, 40 | durationNanos = 300000, 41 | logcat = testFile(), 42 | files = listOf(testFile(), testFile()), 43 | screenshots = listOf(testFile(), testFile()) 44 | ) 45 | ), 46 | passedCount = 2, 47 | ignoredCount = 0, 48 | failedCount = 0, 49 | durationNanos = 500000, 50 | timestampMillis = 123 51 | ) 52 | } 53 | 54 | val htmlShortSuite by memoized { suite.toHtmlShortSuite(id = "testSuite", htmlReportDir = testFile().parentFile) } 55 | 56 | it("converts Suite to HtmlShortSuite") { 57 | assertThat(htmlShortSuite).isEqualTo(HtmlShortSuite( 58 | id = "testSuite", 59 | passedCount = suite.passedCount, 60 | ignoredCount = suite.ignoredCount, 61 | failedCount = suite.failedCount, 62 | durationMillis = NANOSECONDS.toMillis(suite.durationNanos), 63 | devices = suite.devices.map { it.toHtmlDevice(htmlReportDir = testFile().parentFile) } 64 | )) 65 | } 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/html/HtmlFullTest.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.composer.AdbDeviceTest 4 | import com.google.gson.annotations.SerializedName 5 | import java.io.File 6 | import java.util.concurrent.TimeUnit.NANOSECONDS 7 | 8 | data class HtmlFullTest( 9 | 10 | @SerializedName("suite_id") 11 | val suiteId: String, 12 | 13 | @SerializedName("package_name") 14 | val packageName: String, 15 | 16 | @SerializedName("class_name") 17 | val className: String, 18 | 19 | @SerializedName("name") 20 | val name: String, 21 | 22 | @SerializedName("id") 23 | val id: String = "$packageName$className$name", 24 | 25 | @SerializedName("duration_millis") 26 | val durationMillis: Long, 27 | 28 | @SerializedName("status") 29 | val status: Status, 30 | 31 | @SerializedName("stacktrace") 32 | val stacktrace: String?, 33 | 34 | @SerializedName("logcat_path") 35 | val logcatPath: String, 36 | 37 | @SerializedName("deviceId") 38 | val deviceId: String, 39 | 40 | @SerializedName("deviceModel") 41 | val deviceModel: String, 42 | 43 | @SerializedName("properties") 44 | val properties: Map, 45 | 46 | @SerializedName("file_paths") 47 | val filePaths: List, 48 | 49 | @SerializedName("screenshots") 50 | val screenshots: List 51 | ) { 52 | enum class Status { 53 | 54 | @SerializedName("passed") 55 | Passed, 56 | 57 | @SerializedName("failed") 58 | Failed, 59 | 60 | @SerializedName("ignored") 61 | Ignored 62 | } 63 | 64 | data class Screenshot( 65 | 66 | @SerializedName("path") 67 | val path: String, 68 | 69 | @SerializedName("title") 70 | val title: String 71 | ) 72 | } 73 | 74 | fun AdbDeviceTest.toHtmlFullTest(suiteId: String, htmlReportDir: File) = HtmlFullTest( 75 | suiteId = suiteId, 76 | packageName = className.substringBeforeLast("."), 77 | className = className.substringAfterLast("."), 78 | name = testName, 79 | durationMillis = NANOSECONDS.toMillis(durationNanos), 80 | status = when (status) { 81 | AdbDeviceTest.Status.Passed -> HtmlFullTest.Status.Passed 82 | is AdbDeviceTest.Status.Ignored -> HtmlFullTest.Status.Ignored 83 | is AdbDeviceTest.Status.Failed -> HtmlFullTest.Status.Failed 84 | }, 85 | stacktrace = when (status) { 86 | is AdbDeviceTest.Status.Ignored -> status.stacktrace 87 | is AdbDeviceTest.Status.Failed -> status.stacktrace 88 | else -> null 89 | }, 90 | logcatPath = logcat.relativePathTo(htmlReportDir), 91 | deviceId = adbDevice.id, 92 | deviceModel = adbDevice.model, 93 | properties = emptyMap(), // TODO: add properties support. 94 | filePaths = files.map { it.relativePathTo(htmlReportDir) }, 95 | screenshots = screenshots.map { 96 | HtmlFullTest.Screenshot( 97 | path = it.relativePathTo(htmlReportDir), 98 | title = it.nameWithoutExtension 99 | ) 100 | } 101 | ) 102 | -------------------------------------------------------------------------------- /html-report/src/components/TestItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import cx from 'classnames'; 3 | import convertTime from './../utils/convertTime' 4 | import paths from './../utils/paths' 5 | 6 | export default class TestItem extends Component { 7 | componentWillMount() { 8 | document.title = `Test ${window.test.name}`; 9 | } 10 | 11 | render() { 12 | const data = window.test; 13 | let statusLabelClass = cx('label', 'margin-right-10', { 14 | alert: data.status === 'failed', 15 | success: data.status === 'passed' 16 | }); 17 | 18 | return ( 19 |
20 |
21 | Suites list / 22 | Suite { data.suite_id } / 23 | { data.deviceModel } ({ data.deviceId }) 24 |
25 |
26 |
27 |
28 |
29 |
{ data.status }
30 | { data.name }
31 |
{ data.class_name }
32 |
{ data.package_name }
33 |
34 |
{ convertTime(data.duration_millis) }
35 |
36 | 37 | { !!Object.keys(data.properties).length &&
38 |
39 | { Object.keys(data.properties).map((keyName, i) => (
40 |
{`${keyName}:`}
41 |
data.properties[keyName]
42 |
)) } 43 |
44 |
} 45 | 46 | { !!data.file_paths.length &&
47 |
48 | Files 49 |
50 | { data.file_paths.map((file, i) =>
51 |
52 | { file } 53 |
54 |
) } 55 |
} 56 | 57 | { !!data.screenshots.length &&
58 |
Screenshots
59 |
    60 | { data.screenshots.map((image) => { 61 | return (
  • 62 | 63 |
    { image.title }
    64 |
  • ) 65 | }) } 66 |
67 |
} 68 | 69 | { !!data.stacktrace.length &&
70 |
Stacktrace
71 |
{ data.stacktrace }
72 |
} 73 |
74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/JUnitReport.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import com.gojuno.composer.AdbDeviceTest.Status.* 4 | import org.apache.commons.lang3.StringEscapeUtils 5 | import rx.Completable 6 | import rx.Single 7 | import java.io.File 8 | import java.text.SimpleDateFormat 9 | import java.util.* 10 | import java.util.concurrent.TimeUnit.NANOSECONDS 11 | 12 | fun writeJunit4Report(suite: Suite, outputFile: File): Completable = Single 13 | .fromCallable { outputFile.parentFile.mkdirs() } 14 | .map { 15 | fun Long.toJunitSeconds(): String = (NANOSECONDS.toMillis(this) / 1000.0).toString() 16 | 17 | buildString(capacity = suite.tests.size * 150) { 18 | appendln("""""") 19 | 20 | append("") 35 | 36 | apply { 37 | appendln("") 38 | suite.tests.forEach { test -> 39 | append(" { 46 | appendln("/>") 47 | } 48 | is Ignored -> { 49 | appendln(">") 50 | if (test.status.stacktrace.isEmpty()) { 51 | appendln("") 52 | } else { 53 | appendln("") 54 | appendln(StringEscapeUtils.escapeXml10(test.status.stacktrace)) 55 | appendln("") 56 | } 57 | appendln("") 58 | } 59 | is Failed -> { 60 | appendln(">") 61 | 62 | appendln("") 63 | appendln(StringEscapeUtils.escapeXml10(test.status.stacktrace)) 64 | appendln("") 65 | 66 | appendln("") 67 | } 68 | } 69 | } 70 | } 71 | 72 | appendln("") 73 | } 74 | } 75 | .map { xml -> outputFile.writeText(xml) } 76 | .toCompletable() 77 | -------------------------------------------------------------------------------- /composer/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'kotlin' 2 | apply plugin: 'application' 3 | apply plugin: 'org.junit.platform.gradle.plugin' 4 | apply plugin: 'maven' 5 | apply plugin: 'maven-publish' 6 | apply plugin: 'com.jfrog.bintray' 7 | 8 | mainClassName = 'com.gojuno.composer.MainKt' 9 | 10 | dependencies { 11 | compile libraries.kotlinStd 12 | compile libraries.rxJava 13 | compile libraries.jCommander 14 | compile libraries.commanderOs 15 | compile libraries.commanderAndroid 16 | compile libraries.apacheCommonsIo 17 | compile libraries.apacheCommonsLang 18 | compile libraries.gson 19 | compile libraries.dexParser 20 | } 21 | 22 | dependencies { 23 | testCompile libraries.spek 24 | testCompile libraries.junitPlatformRunner 25 | testCompile libraries.spekJunitPlatformEngine 26 | testCompile libraries.assertJ 27 | } 28 | 29 | jar { 30 | // Build jar with dependencies. 31 | from(configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }) { 32 | exclude 'META-INF/*.SF' 33 | exclude 'META-INF/*.DSA' 34 | exclude 'META-INF/*.RSA' 35 | } 36 | 37 | manifest { 38 | attributes('Main-Class': mainClassName) 39 | } 40 | } 41 | 42 | junitPlatform { 43 | platformVersion = versions.junitPlatform 44 | 45 | filters { 46 | engines { 47 | include 'spek' 48 | } 49 | } 50 | } 51 | 52 | task sourcesJar(type: Jar, dependsOn: classes) { 53 | classifier = 'sources' 54 | from sourceSets.main.allSource 55 | } 56 | 57 | task javadocJar(type: Jar, dependsOn: javadoc) { 58 | classifier = 'javadoc' 59 | from javadoc.destinationDir 60 | } 61 | 62 | task validatePublishing { 63 | doLast { 64 | validateTagAndVersion() 65 | } 66 | } 67 | 68 | bintrayUpload.dependsOn validatePublishing 69 | 70 | def pomConfig = { 71 | licenses { 72 | license { 73 | name 'The Apache Software License, Version 2.0' 74 | url 'http://www.apache.org/licenses/LICENSE-2.0.txt' 75 | distribution 'repo' 76 | } 77 | } 78 | developers { 79 | developer { 80 | id 'gojuno' 81 | name 'Juno Inc.' 82 | email 'opensource@gojuno.com' 83 | } 84 | } 85 | } 86 | 87 | publishing { 88 | publications { 89 | ComposerPublication(MavenPublication) { 90 | from components.java 91 | 92 | artifact sourcesJar 93 | artifact javadocJar 94 | 95 | groupId 'com.gojuno.composer' 96 | artifactId 'composer' 97 | version projectVersion() 98 | 99 | pom.withXml { 100 | def root = asNode() 101 | root.appendNode('description', 'Reactive Android Instrumentation Test Runner.') 102 | root.appendNode('name', 'Composer') 103 | root.appendNode('url', 'https://github.com/gojuno/composer') 104 | root.children().last() + pomConfig 105 | } 106 | } 107 | } 108 | } 109 | 110 | bintray { 111 | user = System.getenv('BINTRAY_USER') 112 | key = System.getenv('BINTRAY_API_KEY') 113 | publish = true 114 | 115 | pkg { 116 | repo = 'maven' 117 | name = 'composer' 118 | licenses = ['Apache-2.0'] 119 | vcsUrl = 'https://github.com/gojuno/composer.git' 120 | issueTrackerUrl = 'https://github.com/gojuno/composer/issues' 121 | publications = ['ComposerPublication'] 122 | 123 | version { 124 | name = projectVersion() 125 | vcsTag = gitTag() 126 | 127 | gpg { 128 | sign = true 129 | passphrase = System.getenv('BINTRAY_GPG_PASSPHRASE') 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /html-report/styles/_components.scss: -------------------------------------------------------------------------------- 1 | /** 2 | Main page header 3 | Section menu 4 | **/ 5 | 6 | /* Main page header */ 7 | 8 | .main-header__i { 9 | display: flex; 10 | flex-direction: row; 11 | flex-wrap: nowrap; 12 | height: 70px; 13 | background: $primary-blue; 14 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); 15 | color: $white; 16 | } 17 | 18 | .main-header__item { 19 | line-height: 70px; 20 | color: $light-grey; 21 | } 22 | 23 | .main-header__logo { 24 | width: 180px; 25 | padding-left: 20px; 26 | color: #c5d0ff; 27 | font-size: 15px; 28 | line-height: 73px; 29 | } 30 | 31 | .main-header__logo-img { 32 | width: 50px; 33 | height: 13px; 34 | margin-right: 10px; 35 | fill: $white; 36 | } 37 | 38 | .main-header__logo-title { 39 | position: relative; 40 | top: -1px; 41 | color: $light-grey; 42 | } 43 | 44 | .main-header__section-link { 45 | display: inline-block; 46 | padding: 24px 25px 22px; 47 | line-height: 24px; 48 | color: $light-grey; 49 | 50 | &.active { 51 | background: $electric; 52 | color: $white; 53 | cursor: default; 54 | 55 | &:hover, &:active { 56 | color: $white; 57 | } 58 | } 59 | 60 | &:hover, &:active { 61 | color: $electric; 62 | } 63 | 64 | .label { 65 | margin-left: 5px; 66 | } 67 | } 68 | 69 | .main-header__notification { 70 | padding: 12px $side-padding; 71 | background: $green; 72 | color: $white; 73 | } 74 | 75 | .main-header__notification-link { 76 | color: $white; 77 | border-bottom: 1px solid rgba(255,255,255,.7); 78 | transition: border $short-transition; 79 | 80 | &:hover { 81 | color: $white; 82 | border-color: transparent; 83 | } 84 | } 85 | 86 | .main-header__user { 87 | max-width: 230px; 88 | margin-left: auto; 89 | overflow: hidden; 90 | white-space: nowrap; 91 | text-overflow: ellipsis; 92 | } 93 | 94 | .main-header__logoff { 95 | .button { 96 | display: block; 97 | height: 70px; 98 | margin: 0 0 0 20px; 99 | padding: 0 20px; 100 | color: $light-grey; 101 | } 102 | } 103 | 104 | /* Section menu */ 105 | 106 | .section-menu { 107 | margin-right: 20px; 108 | background: $white; 109 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); 110 | } 111 | 112 | .section-menu__item { 113 | overflow: hidden; 114 | 115 | &:last-child { 116 | .section-menu__link { 117 | border-bottom: none; 118 | } 119 | } 120 | } 121 | 122 | .section-menu__link { 123 | display: block; 124 | position: relative; 125 | padding: 5px 0 5px 20px; 126 | border-left: 4px solid transparent; 127 | line-height: 30px; 128 | color: $primary-grey; 129 | cursor: pointer; 130 | transition-property: color, background; 131 | transition-duration: $short-transition; 132 | 133 | &:hover { 134 | color: $electric; 135 | } 136 | 137 | &.active { 138 | font-weight: 500; 139 | color: $electric; 140 | cursor: default; 141 | 142 | &:not(.with-inner-menu) { 143 | border-color: $electric; 144 | background: $electric-lightest; 145 | } 146 | 147 | &.with-inner-menu { 148 | margin-bottom: 8px; 149 | } 150 | 151 | .icon.expand { 152 | top: 18px; 153 | border-color: $electric; 154 | transform: rotate(45deg); 155 | } 156 | } 157 | 158 | .icon.expand { 159 | position: absolute; 160 | top: 14px; 161 | right: 20px; 162 | border-left: 1px solid $primary-grey; 163 | border-top: 1px solid $primary-grey; 164 | transition: transform $short-transition; 165 | } 166 | } 167 | 168 | .inner-menu { 169 | max-height: 0; 170 | overflow: hidden; 171 | } 172 | 173 | .active + .inner-menu { 174 | max-height: 1000px; 175 | } 176 | 177 | .inner-menu__link { 178 | display: block; 179 | padding-left: 20px; 180 | border-left: 4px solid transparent; 181 | font-size: $font-small; 182 | line-height: 30px; 183 | 184 | &.active { 185 | border-color: $electric; 186 | font-weight: 500; 187 | color: $electric; 188 | background: $electric-lightest; 189 | cursor: default; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /html-report/styles/_custom.scss: -------------------------------------------------------------------------------- 1 | /* Layout */ 2 | 3 | @define-mixin wordBreak { 4 | overflow-wrap: break-word; 5 | word-wrap: break-word; 6 | -ms-word-break: break-all; 7 | word-break: break-word; 8 | hyphens: none; 9 | } 10 | 11 | .container-expanded { 12 | margin: 0 -$side-padding; 13 | } 14 | 15 | @media $large-screen { 16 | .container-expanded { 17 | margin: 0 -$side-padding-desktop; 18 | } 19 | } 20 | 21 | .margin-bottom-5 { 22 | margin-bottom: 5px; 23 | } 24 | 25 | .bounded { 26 | max-width: 700px; 27 | } 28 | 29 | .shortened { 30 | overflow: hidden; 31 | white-space: nowrap; 32 | text-overflow: ellipsis; 33 | } 34 | 35 | .title-l { 36 | color: $electric; 37 | } 38 | 39 | .test-page { 40 | &.failed { 41 | border-top: 5px solid $red; 42 | } 43 | 44 | &.ignored { 45 | border-top: 5px solid $primary-grey; 46 | } 47 | 48 | &.passed { 49 | border-top: 5px solid $green; 50 | } 51 | } 52 | 53 | .test-page__title { 54 | font-size: 20px; 55 | line-height: 22px; 56 | font-weight: 500; 57 | } 58 | 59 | /* Statuses */ 60 | .status-passed { 61 | color: $green; 62 | } 63 | 64 | .status-ignored { 65 | color: $primary-grey; 66 | } 67 | 68 | .status-failed { 69 | color: $red; 70 | } 71 | 72 | .with-arrow { 73 | display: inline-block; 74 | position: relative; 75 | padding-right: 12px; 76 | 77 | &:before { 78 | content: ''; 79 | display: block; 80 | position: absolute; 81 | right: 0; 82 | top: 6px; 83 | border-top: 4px solid transparent; 84 | border-bottom: 4px solid transparent; 85 | border-left: 4px solid $primary-blue; 86 | } 87 | 88 | &:hover { 89 | &:before { 90 | border-left: 4px solid $electric; 91 | } 92 | } 93 | } 94 | 95 | /* List */ 96 | 97 | .list__item { 98 | position: relative; 99 | padding: 14px $side-padding; 100 | color: $primary-blue; 101 | 102 | &:before { 103 | content: ''; 104 | display: block; 105 | position: absolute; 106 | left: 0; 107 | top: 0; 108 | bottom: 0; 109 | width: 0; 110 | } 111 | 112 | &.passed { 113 | border-left: 5px solid $green; 114 | } 115 | 116 | &.ignored { 117 | border-left: 5px solid $primary-grey-lighter; 118 | } 119 | 120 | &.failed { 121 | border-left: 5px solid $red; 122 | } 123 | 124 | &:not(.no-hover):hover { 125 | cursor: pointer; 126 | z-index: 2; 127 | box-shadow: 0 0 3px 0 $primary-grey-lighter-2; 128 | color: $primary-blue; 129 | } 130 | 131 | &:nth-child(odd) { 132 | background: $background; 133 | } 134 | } 135 | 136 | .images__item { 137 | box-sizing: border-box; 138 | flex: 0 0 25%; 139 | padding: 0 $side-padding $side-padding 0; 140 | 141 | img { 142 | width: 100%; 143 | vertical-align: middle; 144 | } 145 | } 146 | 147 | .images__item__title { 148 | @mixin wordBreak; 149 | } 150 | 151 | @media $large-screen { 152 | .images__item { 153 | flex: 0 0 20%; 154 | } 155 | .list__item { 156 | padding: $side-padding -$side-padding-desktop; 157 | } 158 | } 159 | 160 | /* Labels list */ 161 | 162 | .labels-list { 163 | text-align: right; 164 | } 165 | 166 | /* Filter card */ 167 | 168 | .filter-card { 169 | cursor: pointer; 170 | transition: transform $short-transition; 171 | transform: translateY(0); 172 | 173 | &:hover { 174 | transform: translateY(-5px); 175 | } 176 | } 177 | 178 | /* Log */ 179 | 180 | .log-wrapper { 181 | min-width: 1160px; 182 | max-width: 1860px; 183 | margin: 0 auto; 184 | padding: 0 20px; 185 | } 186 | 187 | .log { 188 | background: #202020; 189 | color: rgba(255,255,255,.8); 190 | } 191 | 192 | .log__line { 193 | overflow-wrap: break-word; 194 | word-wrap: break-word; 195 | -ms-word-break: break-all; 196 | word-break: break-word; 197 | hyphens: none; 198 | } 199 | 200 | .log__default { 201 | color: rgba(255,255,255,.8); 202 | } 203 | 204 | .log__verbose { 205 | color: rgba(255,255,255,.7); 206 | } 207 | 208 | .log__debug { 209 | color: #1D91BB; 210 | } 211 | 212 | .log__info { 213 | color: #53C8A1; 214 | } 215 | 216 | .log__warning { 217 | color: #DBBE10; 218 | } 219 | 220 | .log__error { 221 | color: #FF6B68; 222 | } 223 | 224 | .log__assert { 225 | color: #C200FF; 226 | } 227 | 228 | .copy { 229 | color: #879bab; 230 | font-size: 12px; 231 | padding-bottom: 10px; 232 | text-align: right; 233 | } 234 | 235 | .label.big { 236 | height: 25px; 237 | font-size: 16px; 238 | } 239 | -------------------------------------------------------------------------------- /html-report/styles/_layout.scss: -------------------------------------------------------------------------------- 1 | /** 2 | Main wrapper 3 | Grid 4 | Flex container 5 | Columns 6 | Margin for columns 7 | Margins for blocks 8 | Limited in width block 9 | Centered block 10 | Vertically aligned content in a row 11 | Container to be 100% (or max possible) width of a parent 12 | Right aligned block 13 | Separated sections 14 | **/ 15 | 16 | $main-container-min-width: 1200px; 17 | $page-padding-mobile: 20px; 18 | $page-padding-tablet: 20px; 19 | $page-padding-desktop: 20px; 20 | $side-padding: 20px; 21 | $side-padding-desktop: 40px; 22 | $margin: 20px; 23 | 24 | /* Main wrapper */ 25 | 26 | .page-content { 27 | min-width: $main-container-min-width; 28 | } 29 | 30 | /* Wrapper for content of the page */ 31 | 32 | .content { 33 | max-width: 1860px; 34 | margin: 0 auto; 35 | padding: 0 $page-padding-desktop; 36 | } 37 | 38 | /** 39 | Grid 40 | **/ 41 | 42 | /* Flex container, row wrapper, gives horizontal direction */ 43 | 44 | .row { 45 | display: flex; 46 | flex-wrap: wrap; 47 | margin-right: -$margin; 48 | 49 | &.full { 50 | margin-right: 0; 51 | } 52 | 53 | &.rtl { 54 | justify-content: flex-end; 55 | } 56 | 57 | &.no-wrap { 58 | flex-wrap: nowrap; 59 | } 60 | } 61 | 62 | /* Columns in different sizes, the number represents the actual size */ 63 | 64 | [class^="col-"] { 65 | box-sizing: border-box; 66 | padding-right: $margin; 67 | } 68 | 69 | .col-100 { 70 | flex: 0 0 100%; 71 | } 72 | 73 | .col-15 { 74 | flex: 0 0 15%; 75 | } 76 | 77 | .col-20 { 78 | flex: 0 0 20%; 79 | } 80 | 81 | .col-25 { 82 | flex: 0 0 25%; 83 | } 84 | 85 | .col-30 { 86 | flex: 0 0 30%; 87 | } 88 | 89 | .col-40 { 90 | flex: 0 0 40%; 91 | } 92 | 93 | .col-50 { 94 | flex: 0 0 50%; 95 | } 96 | 97 | .col-60 { 98 | flex: 0 0 60%; 99 | } 100 | 101 | .col-70 { 102 | flex: 0 0 70%; 103 | } 104 | 105 | .col-75 { 106 | flex: 0 0 75%; 107 | } 108 | 109 | .col-80 { 110 | flex: 0 0 80%; 111 | } 112 | 113 | /* Margin for columns 114 | In react components used only direction + width (f.ex. 'left-20') 115 | */ 116 | 117 | .offset-all-0 { 118 | padding: 0; 119 | } 120 | 121 | .offset-all-20 { 122 | padding-right: 20px; 123 | } 124 | 125 | .offset-right-20 { 126 | padding-right: 20px; 127 | } 128 | 129 | .offset-left-20 { 130 | padding-left: 20px; 131 | } 132 | 133 | .offset-bottom-20 { 134 | padding-bottom: 20px; 135 | } 136 | 137 | .offset-top-20 { 138 | padding-top: 20px; 139 | } 140 | 141 | /* Margins for blocks */ 142 | 143 | .margin-all-20 { 144 | margin: 20px; 145 | } 146 | 147 | .margin-left-20 { 148 | margin-left: 20px; 149 | } 150 | 151 | .margin-right-10 { 152 | margin-right: 10px; 153 | } 154 | 155 | .margin-right-20 { 156 | margin-right: 20px; 157 | } 158 | 159 | .margin-right-30 { 160 | margin-right: 30px; 161 | } 162 | 163 | .margin-bottom-0.margin-bottom-0 { 164 | margin-bottom: 0; 165 | } 166 | 167 | .margin-bottom-10 { 168 | margin-bottom: 10px; 169 | } 170 | 171 | .margin-bottom-20 { 172 | margin-bottom: 20px; 173 | } 174 | 175 | .margin-bottom-30 { 176 | margin-bottom: 30px; 177 | } 178 | 179 | .margin-top-10 { 180 | margin-top: 10px; 181 | } 182 | 183 | .margin-top-20 { 184 | margin-top: 20px; 185 | } 186 | 187 | .margin-top-40 { 188 | margin-top: 40px; 189 | } 190 | 191 | .margin-left-right-20 { 192 | margin: 0 20px; 193 | } 194 | 195 | .margin-top-bottom-20 { 196 | margin: 20px 0; 197 | } 198 | 199 | /* Limited in width block */ 200 | 201 | .bounded { 202 | max-width: 1100px; 203 | } 204 | 205 | /* Centered block */ 206 | 207 | .centered { 208 | margin: 0 auto; 209 | } 210 | 211 | /* Vertically aligned content in a row */ 212 | 213 | .vertical-aligned-content { 214 | display: flex; 215 | align-items: center; 216 | } 217 | 218 | /* Container to be 100% (or max possible) width of a parent */ 219 | 220 | .full-width-content { 221 | flex-grow: 1; 222 | flex-shrink: 1; 223 | } 224 | 225 | /* Right aligned block */ 226 | 227 | .right-aligned-block { 228 | margin-left: auto; 229 | } 230 | 231 | /* Justified content between */ 232 | 233 | .justify-between { 234 | justify-content: space-between; 235 | } 236 | 237 | /* Adaptive padding */ 238 | 239 | .side-paddings { 240 | padding-left: $side-padding; 241 | padding-right: $side-padding; 242 | } 243 | 244 | @media $large-screen { 245 | .side-paddings { 246 | padding-left: $side-padding-desktop; 247 | padding-right: $side-padding-desktop; 248 | } 249 | } 250 | 251 | /* Separated sections */ 252 | 253 | .separated-section { 254 | position: relative; 255 | margin: 0 -$side-padding $side-padding; 256 | padding: 40px; 257 | background: $background; 258 | 259 | .form-container { 260 | position: static; 261 | } 262 | } 263 | 264 | @media $large-screen { 265 | .separated-section { 266 | margin: 0 -$side-padding-desktop $side-padding-desktop; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/html/HtmlReport.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer.html 2 | 3 | import com.gojuno.composer.Suite 4 | import com.google.gson.Gson 5 | import org.apache.commons.lang3.StringEscapeUtils 6 | import rx.Completable 7 | import java.io.File 8 | import java.io.InputStream 9 | import java.text.SimpleDateFormat 10 | import java.util.* 11 | 12 | /** 13 | * Following file tree structure will be created: 14 | * - index.json 15 | * - suites/suiteId.json 16 | * - suites/deviceId/testId.json 17 | */ 18 | fun writeHtmlReport(gson: Gson, suites: List, outputDir: File, date: Date): Completable = Completable.fromCallable { 19 | outputDir.mkdirs() 20 | 21 | val htmlIndexJson = gson.toJson( 22 | HtmlIndex( 23 | suites = suites.mapIndexed { index, suite -> suite.toHtmlShortSuite(id = "$index", htmlReportDir = outputDir) } 24 | ) 25 | ) 26 | 27 | val formattedDate = SimpleDateFormat("HH:mm:ss z, MMM d yyyy").apply { timeZone = TimeZone.getTimeZone("UTC") }.format(date) 28 | 29 | val appJs = File(outputDir, "app.min.js") 30 | inputStreamFromResources("html-report/app.min.js").copyTo(appJs.outputStream()) 31 | 32 | val appCss = File(outputDir, "app.min.css") 33 | inputStreamFromResources("html-report/app.min.css").copyTo(appCss.outputStream()) 34 | 35 | // index.html is a page that can render all kinds of inner pages: Index, Suite, Test. 36 | val indexHtml = inputStreamFromResources("html-report/index.html").reader().readText() 37 | 38 | val indexHtmlFile = File(outputDir, "index.html") 39 | 40 | fun File.relativePathToHtmlDir(): String = outputDir.relativePathTo(this.parentFile).let { relativePath -> 41 | when (relativePath) { 42 | "" -> relativePath 43 | else -> "$relativePath/" 44 | } 45 | } 46 | 47 | indexHtmlFile.writeText(indexHtml 48 | .replace("\${relative_path}", indexHtmlFile.relativePathToHtmlDir()) 49 | .replace("\${data_json}", "window.mainData = $htmlIndexJson") 50 | .replace("\${date}", formattedDate) 51 | .replace("\${log}", "") 52 | ) 53 | 54 | val suitesDir = File(outputDir, "suites").apply { mkdirs() } 55 | 56 | suites.mapIndexed { suiteId, suite -> 57 | val suiteJson = gson.toJson(suite.toHtmlFullSuite(id = "$suiteId", htmlReportDir = suitesDir)) 58 | val suiteHtmlFile = File(suitesDir, "$suiteId.html") 59 | 60 | suiteHtmlFile.writeText(indexHtml 61 | .replace("\${relative_path}", suiteHtmlFile.relativePathToHtmlDir()) 62 | .replace("\${data_json}", "window.suite = $suiteJson") 63 | .replace("\${date}", formattedDate) 64 | .replace("\${log}", "") 65 | ) 66 | 67 | suite 68 | .tests 69 | .map { it to File(File(suitesDir, "$suiteId"), it.adbDevice.id).apply { mkdirs() } } 70 | .map { (test, testDir) -> Triple(test, test.toHtmlFullTest(suiteId = "$suiteId", htmlReportDir = testDir), testDir) } 71 | .forEach { (test, htmlTest, testDir) -> 72 | val testJson = gson.toJson(htmlTest) 73 | val testHtmlFile = File(testDir, "${htmlTest.id}.html") 74 | 75 | testHtmlFile.writeText(indexHtml 76 | .replace("\${relative_path}", testHtmlFile.relativePathToHtmlDir()) 77 | .replace("\${data_json}", "window.test = $testJson") 78 | .replace("\${date}", formattedDate) 79 | .replace("\${log}", generateLogcatHtml(test.logcat)) 80 | ) 81 | } 82 | } 83 | } 84 | 85 | /** 86 | * Fixed version of `toRelativeString()` from Kotlin stdlib that forces use of absolute file paths. 87 | * See https://youtrack.jetbrains.com/issue/KT-14056 88 | */ 89 | fun File.relativePathTo(base: File): String = absoluteFile.toRelativeString(base.absoluteFile) 90 | 91 | fun inputStreamFromResources(path: String): InputStream = Suite::class.java.classLoader.getResourceAsStream(path) 92 | 93 | fun generateLogcatHtml(logcatOutput: File): String = when (logcatOutput.exists()) { 94 | false -> "" 95 | true -> logcatOutput 96 | .readLines() 97 | .map { line -> """
${StringEscapeUtils.escapeXml11(line)}
""" } 98 | .fold(StringBuilder("""
""")) { stringBuilder, line -> 99 | stringBuilder.appendln(line) 100 | } 101 | .appendln("""
""") 102 | .toString() 103 | } 104 | 105 | fun cssClassForLogcatLine(logcatLine: String): String { 106 | // Logcat line example: `06-07 16:55:14.490 2100 2100 I MicroDetectionWorker: #onError(false)` 107 | // First letter is Logcat level. 108 | return when (logcatLine.firstOrNull { it.isLetter() }) { 109 | 'V' -> "verbose" 110 | 'D' -> "debug" 111 | 'I' -> "info" 112 | 'W' -> "warning" 113 | 'E' -> "error" 114 | 'A' -> "assert" 115 | else -> "default" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /html-report/styles/_form.scss: -------------------------------------------------------------------------------- 1 | @import "./helpers/index.scss"; 2 | 3 | /* Form elements */ 4 | 5 | [type='text'], 6 | [type='password'], 7 | [type='email'], 8 | [type='tel'], 9 | textarea, 10 | select { 11 | box-sizing: border-box; 12 | width: 100%; 13 | height: 30px; 14 | padding: 8px 12px 8px 0; 15 | border: none; 16 | border-bottom: 1px dashed $primary-grey-lighter; 17 | border-radius: 0; 18 | font-family: inherit; 19 | font-size: $font-normal; 20 | color: $primary-blue; 21 | background: transparent; 22 | -webkit-appearance: none; 23 | -moz-appearance: none; 24 | } 25 | 26 | [type='text']:focus, 27 | [type='password']:focus, 28 | [type='tel']:focus, 29 | [type='email']:focus, 30 | textarea:focus, 31 | select:focus { 32 | border-bottom: 1px solid $electric; 33 | box-shadow: 0 1px 0 0 $electric; 34 | outline: none; 35 | position: relative; 36 | z-index: 2; 37 | } 38 | 39 | [type='text']:disabled, 40 | [type='password']:disabled, 41 | [type='tel']:disabled, 42 | [type='email']:disabled, 43 | textarea:disabled, 44 | select:disabled { 45 | color: $primary-grey; 46 | } 47 | 48 | ::-webkit-input-placeholder { 49 | color: $primary-grey; 50 | } 51 | 52 | .form-item { 53 | position: relative; 54 | } 55 | 56 | .input-group { 57 | .form-item { 58 | flex-grow: 1; 59 | flex-shrink: 1; 60 | margin-right: 20px; 61 | } 62 | } 63 | 64 | .form-item__error-text { 65 | display: none; 66 | top: 100%; 67 | margin-top: 4px; 68 | @extend .text-error-text; 69 | 70 | &.visible { 71 | display: block; 72 | } 73 | } 74 | 75 | /* Input */ 76 | 77 | .is-invalid-input, 78 | .is-invalid-input:focus { 79 | position: relative; 80 | z-index: 3; 81 | border-color: $red; 82 | border-bottom-style: solid; 83 | } 84 | 85 | .is-invalid-input:focus, .is-invalid-input:active { 86 | box-shadow: 0 1px 0 0 $red; 87 | } 88 | 89 | 90 | /* Select */ 91 | 92 | select { 93 | border-radius: 0; 94 | padding: 0 22px 0 0; 95 | 96 | &.empty { 97 | color: $primary-grey; 98 | } 99 | } 100 | 101 | .select { 102 | position: relative; 103 | @mixin with-arrow; 104 | } 105 | 106 | /* Switch button */ 107 | 108 | .toggler { 109 | position: relative; 110 | height: 32px; 111 | user-select: none; 112 | } 113 | 114 | .toggler__checkbox { 115 | position: absolute; 116 | left: 0; 117 | top: 0; 118 | opacity: 0; 119 | } 120 | 121 | // Inactive state 122 | .toggler__label { 123 | display: block; 124 | position: relative; 125 | width: 74px; 126 | height: 32px; 127 | border: 1px solid $red; 128 | border-radius: 32px; 129 | 130 | &:before { 131 | content: 'No'; 132 | display: block; 133 | position: absolute; 134 | left: 0; 135 | right: 0; 136 | top: 8px; 137 | text-transform: uppercase; 138 | text-align: center; 139 | font-size: $font-small; 140 | font-weight: bold; 141 | color: $red; 142 | } 143 | 144 | &.checked { 145 | border: 1px solid $green; 146 | 147 | &:before { 148 | content: 'Yes'; 149 | color: $green; 150 | } 151 | } 152 | } 153 | 154 | // Active state 155 | .toggler__label.active { 156 | cursor: pointer; 157 | background: $red; 158 | 159 | &:after { 160 | content: ''; 161 | position: absolute; 162 | top: 1px; 163 | left: 1px; 164 | display: block; 165 | width: 30px; 166 | height: 30px; 167 | background-color: $white; 168 | border-radius: 30px; 169 | box-shadow: 2px 0 0 0 rgba(0, 0, 0, 0.1); 170 | transition: all $short-transition ease-in-out; 171 | } 172 | 173 | &:before { 174 | left: auto; 175 | right: 14px; 176 | color: $white; 177 | } 178 | 179 | &.checked { 180 | background: $green; 181 | 182 | &:before { 183 | right: auto; 184 | left: 10px; 185 | } 186 | 187 | &:after { 188 | left: 43px; 189 | } 190 | } 191 | } 192 | 193 | /* Radio Group */ 194 | 195 | .radio-group { 196 | display: flex; 197 | flex-direction: row; 198 | flex-wrap: wrap; 199 | 200 | &.is-invalid-input { 201 | box-shadow: none; 202 | 203 | .radio-button__label { 204 | color: $red; 205 | } 206 | } 207 | } 208 | 209 | .radio-button { 210 | margin: 0 15px 0 0; 211 | } 212 | 213 | .radio-button__label { 214 | display: inline-block; 215 | position: relative; 216 | padding-left: 30px; 217 | text-transform: capitalize; 218 | cursor: pointer; 219 | } 220 | 221 | .radio-button__input { 222 | -webkit-appearance: none; 223 | position: absolute; 224 | left: 0; 225 | top: 3px; 226 | margin: 0; 227 | } 228 | 229 | .radio-button__icon { 230 | content: ''; 231 | display: block; 232 | box-sizing: border-box; 233 | position: absolute; 234 | left: 0; 235 | top: 0; 236 | width: 20px; 237 | height: 20px; 238 | border-radius: 20px; 239 | border: 1px solid $primary-grey; 240 | background: $white; 241 | 242 | &.checked { 243 | background: $electric; 244 | 245 | &:before { 246 | content: ''; 247 | display: block; 248 | position: absolute; 249 | left: 6px; 250 | top: 6px; 251 | width: 6px; 252 | height: 6px; 253 | border-radius: 6px; 254 | background: $white; 255 | } 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /html-report/src/components/SearchBar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import cx from 'classnames'; 4 | import elasticlunr from 'elasticlunr'; 5 | import convertTime from './../utils/convertTime' 6 | 7 | const SEARCH_FIELDS = ['package_name', 'class_name', 'name', 'id', 'status']; 8 | const SEARCH_REF = 'id'; 9 | const EL_SEARCH = elasticlunr(); 10 | export default class SearchBar extends Component { 11 | static propTypes = { 12 | setSearchResults: PropTypes.func 13 | }; 14 | 15 | state = { 16 | data: window.suite.tests, 17 | error: false, 18 | searchLabel: null, 19 | searchParams: null, 20 | query: '' 21 | }; 22 | 23 | componentWillMount() { 24 | let { data } = this.state; 25 | elasticlunr.clearStopWords(); 26 | SEARCH_FIELDS.forEach(f => EL_SEARCH.addField(f)) 27 | EL_SEARCH.setRef(SEARCH_REF); 28 | if (data.length) { 29 | data.forEach(item => EL_SEARCH.addDoc(item)) 30 | } 31 | } 32 | 33 | mapResults(results) { 34 | return results.map(item => { 35 | return EL_SEARCH.documentStore.docs[item.ref]; 36 | }) 37 | } 38 | 39 | clearResults = () => { 40 | this.props.setSearchResults(this.state.data); 41 | this.setState({ searchLabel: null, searchParams: null, error: false, query: '' }); 42 | }; 43 | 44 | setTagSearch = (field, callback) => { 45 | if (SEARCH_FIELDS.indexOf(field) < 0) { 46 | this.setState({ error: true }); 47 | return; 48 | } 49 | 50 | let params = {}; 51 | params.fields = {}; 52 | SEARCH_FIELDS.forEach((f) => { 53 | if (f === field) { 54 | params.fields[f] = { boost: 1 } 55 | } else { 56 | params.fields[f] = { boost: 0 } 57 | } 58 | }); 59 | 60 | this.setState({ searchLabel: field, searchParams: params, query: '' }, callback) 61 | }; 62 | 63 | performSearch = (query) => { 64 | let searchParameters = { expand: true }; 65 | if (this.state.searchParams) { 66 | Object.assign(searchParameters, this.state.searchParams) 67 | } 68 | let results = EL_SEARCH.search(query, searchParameters); 69 | this.props.setSearchResults(this.mapResults(results)) 70 | }; 71 | 72 | performFilterSearch = (query) => { 73 | const splitData = query.split(':'); 74 | this.setTagSearch(splitData[0], () => { 75 | this.performSearch(splitData[1]); 76 | this.setState({ query: splitData[1] }); 77 | }); 78 | }; 79 | 80 | setSearchQuery = (event) => { 81 | let val = event.target.value; 82 | this.setState({ query: val, error: false }); 83 | 84 | if (!val) { 85 | if (this.state.searchLabel) return; 86 | this.clearResults(); 87 | return; 88 | } 89 | 90 | if (val.indexOf(':') < 0) { 91 | this.performSearch(val) 92 | } else { 93 | this.setTagSearch(val.split(':')[0]) 94 | } 95 | }; 96 | 97 | render() { 98 | let errorTextClasses = cx('form-item__error-text col-100', { visible: this.state.error }); 99 | let errorInputClasses = cx({ 'is-invalid-input': this.state.error }); 100 | const data = window.suite; 101 | 102 | return ( 103 |
104 |
105 |
this.performFilterSearch('status:passed') }> 106 |
Passed
107 |
{ data.passed_count }
108 |
109 |
this.performFilterSearch('status:failed') }> 110 |
Failed
111 |
{ data.failed_count }
112 |
113 |
this.performFilterSearch('status:ignored') }> 114 |
Ignored
115 |
{ data.ignored_count }
116 |
117 |
118 |
Duration
119 |
{ convertTime(data.duration_millis) }
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | { this.state.searchLabel &&
{ this.state.searchLabel }:
} 129 | 131 | 134 |
135 |
No such key exists!
136 |
137 |
138 |
139 |
140 |
141 |
142 | ) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save ( ) { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/Args.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import com.beust.jcommander.IStringConverter 4 | import com.beust.jcommander.JCommander 5 | import com.beust.jcommander.Parameter 6 | import java.util.concurrent.TimeUnit 7 | 8 | data class Args( 9 | @Parameter( 10 | names = arrayOf("--apk"), 11 | required = true, 12 | description = "Path to application apk that needs to be tested.", 13 | order = 0 14 | ) 15 | var appApkPath: String = "", 16 | 17 | @Parameter( 18 | names = arrayOf("--test-apk"), 19 | required = true, 20 | description = "Path to apk with tests.", 21 | order = 1 22 | ) 23 | var testApkPath: String = "", 24 | 25 | @Parameter( 26 | names = arrayOf("--test-runner"), 27 | required = false, 28 | description = "Fully qualified name of test runner class you're using. Will be parsed from test APK if not specified excplicitly.", 29 | order = 2 30 | ) 31 | var testRunner: String = "", 32 | 33 | @Parameter( 34 | names = arrayOf("--shard"), 35 | required = false, 36 | arity = 1, 37 | description = "Either `true` or `false` to enable/disable test sharding which runs tests in parallel on available devices/emulators. `true` by default.", 38 | order = 3 39 | ) 40 | var shard: Boolean = true, 41 | 42 | @Parameter( 43 | names = arrayOf("--output-directory"), 44 | required = false, 45 | description = "Either relative or absolute path to directory for output: reports, files from devices and so on. `composer-output` by default.", 46 | order = 4 47 | ) 48 | var outputDirectory: String = "composer-output", 49 | 50 | @Parameter( 51 | names = arrayOf("--instrumentation-arguments"), 52 | required = false, 53 | variableArity = true, 54 | description = "Key-value pairs to pass to Instrumentation Runner. Usage example: `--instrumentation-arguments myKey1 myValue1 myKey2 myValue2`.", 55 | listConverter = InstrumentationArgumentsConverter::class, 56 | order = 5 57 | ) 58 | var instrumentationArguments: List = listOf(), 59 | 60 | @Parameter( 61 | names = arrayOf("--verbose-output"), 62 | required = false, 63 | arity = 1, 64 | description = "Either `true` or `false` to enable/disable verbose output for Composer. `false` by default.", 65 | order = 6 66 | ) 67 | var verboseOutput: Boolean = false, 68 | 69 | @Parameter( 70 | names = arrayOf("--keep-output-on-exit"), 71 | required = false, 72 | description = "Either `true` or `false` to keep/clean output on exit. `false` by default.", 73 | order = 7 74 | ) 75 | var keepOutputOnExit: Boolean = false, 76 | 77 | @Parameter( 78 | names = arrayOf("--devices"), 79 | required = false, 80 | variableArity = true, 81 | description = "Connected devices/emulators that will be used to run tests against. If not passed — tests will run on all connected devices/emulators. Specifying both `--devices` and `--device-pattern` will result in an error. Usage example: `--devices emulator-5554 emulator-5556`.", 82 | order = 8 83 | ) 84 | var devices: List = emptyList(), 85 | 86 | @Parameter( 87 | names = arrayOf("--device-pattern"), 88 | required = false, 89 | description = "Connected devices/emulators that will be used to run tests against. If not passed — tests will run on all connected devices/emulators. Specifying both `--device-pattern` and `--devices` will result in an error. Usage example: `--device-pattern \"somePatterns\"`.", 90 | order = 9 91 | ) 92 | var devicePattern: String = "", 93 | 94 | @Parameter( 95 | names = arrayOf("--install-timeout"), 96 | required = false, 97 | description = "APK installation timeout in seconds. If not passed defaults to 120 seconds (2 minutes). Applicable to both test APK and APK under test.", 98 | order = 10 99 | ) 100 | var installTimeoutSeconds: Int = TimeUnit.MINUTES.toSeconds(2).toInt(), 101 | 102 | @Parameter( 103 | names = arrayOf("--fail-if-no-tests"), 104 | required = false, 105 | arity = 1, 106 | description = "Either `true` or `false` to enable/disable error on empty test suite. True by default.", 107 | order = 11 108 | ) 109 | var failIfNoTests: Boolean = true, 110 | 111 | @Parameter( 112 | names = arrayOf("--with-orchestrator"), 113 | required = false, 114 | arity = 1, 115 | description = "Either `true` or `false` to enable/disable running tests via AndroidX Test Orchestrator. False by default.", 116 | order = 12 117 | ) 118 | var runWithOrchestrator: Boolean = false, 119 | 120 | @Parameter( 121 | names = arrayOf("--extra-apks"), 122 | required = false, 123 | variableArity = true, 124 | description = "Extra APKs you would usually put on androidTestUtil", 125 | order = 13 126 | ) 127 | var extraApks: List = emptyList() 128 | ) 129 | 130 | // No way to share array both for runtime and annotation without reflection. 131 | private val PARAMETER_HELP_NAMES = setOf("--help", "-help", "help", "-h") 132 | 133 | private fun validateArguments(args: Args) { 134 | if (!args.devicePattern.isEmpty() && !args.devices.isEmpty()) { 135 | throw IllegalArgumentException("Specifying both --devices and --device-pattern is prohibited.") 136 | } 137 | } 138 | 139 | fun parseArgs(rawArgs: Array) = Args().also { args -> 140 | if (PARAMETER_HELP_NAMES.any { rawArgs.contains(it) }) { 141 | JCommander(args).usage() 142 | exit(Exit.Ok) 143 | } 144 | 145 | JCommander.newBuilder() 146 | .addObject(args) 147 | .build() 148 | .parse(*rawArgs) 149 | validateArguments(args) 150 | } 151 | 152 | private class InstrumentationArgumentsConverter : IStringConverter> { 153 | override fun convert(argument: String): List = listOf(argument) 154 | } 155 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/JUnitReportSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import com.gojuno.commander.android.AdbDevice 4 | import com.gojuno.composer.AdbDeviceTest.Status.* 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.jetbrains.spek.api.Spek 7 | import org.jetbrains.spek.api.dsl.context 8 | import org.jetbrains.spek.api.dsl.it 9 | import rx.observers.TestSubscriber 10 | import java.util.concurrent.TimeUnit.MILLISECONDS 11 | import java.util.concurrent.TimeUnit.SECONDS 12 | 13 | class JUnitReportSpec : Spek({ 14 | 15 | val LF = System.getProperty("line.separator") 16 | 17 | context("write test run result as junit4 report to file") { 18 | 19 | val adbDevice by memoized { AdbDevice(id = "testDevice", online = true) } 20 | val subscriber by memoized { TestSubscriber() } 21 | val outputFile by memoized { testFile() } 22 | 23 | perform { 24 | writeJunit4Report( 25 | suite = Suite( 26 | testPackage = "com.gojuno.test", 27 | devices = listOf(Device( 28 | id = adbDevice.id, 29 | logcat = testFile(), 30 | instrumentationOutput = testFile(), 31 | model = adbDevice.model 32 | )), 33 | tests = listOf( 34 | AdbDeviceTest( 35 | adbDevice = adbDevice, 36 | className = "test.class.name1", 37 | testName = "test1", 38 | status = Passed, 39 | durationNanos = SECONDS.toNanos(2), 40 | logcat = testFile(), 41 | files = emptyList(), 42 | screenshots = emptyList() 43 | ), 44 | AdbDeviceTest( 45 | adbDevice = adbDevice, 46 | className = "test.class.name2", 47 | testName = "test2", 48 | status = Failed(stacktrace = "multi${LF}line${LF}stacktrace"), 49 | durationNanos = MILLISECONDS.toNanos(3250), 50 | logcat = testFile(), 51 | files = emptyList(), 52 | screenshots = emptyList() 53 | ), 54 | AdbDeviceTest( 55 | adbDevice = adbDevice, 56 | className = "test.class.name3", 57 | testName = "test3", 58 | status = Passed, 59 | durationNanos = SECONDS.toNanos(1), 60 | logcat = testFile(), 61 | files = emptyList(), 62 | screenshots = emptyList() 63 | ), 64 | AdbDeviceTest( 65 | adbDevice = adbDevice, 66 | className = "test.class.name4", 67 | testName = "test4", 68 | status = Ignored(""), 69 | durationNanos = SECONDS.toNanos(0), 70 | logcat = testFile(), 71 | files = emptyList(), 72 | screenshots = emptyList() 73 | ), 74 | AdbDeviceTest( 75 | adbDevice = adbDevice, 76 | className = "test.class.name5", 77 | testName = "test5", 78 | status = Ignored("multi${LF}line${LF}stacktrace"), 79 | durationNanos = SECONDS.toNanos(0), 80 | logcat = testFile(), 81 | files = emptyList(), 82 | screenshots = emptyList() 83 | ) 84 | ), 85 | passedCount = 2, 86 | ignoredCount = 2, 87 | failedCount = 1, 88 | durationNanos = MILLISECONDS.toNanos(6250), 89 | timestampMillis = 1490200150000 90 | ), 91 | outputFile = outputFile 92 | ).subscribe(subscriber) 93 | } 94 | 95 | it("produces correct xml report") { 96 | var expected = """ 97 | 98 | 99 | 100 | 101 | 102 | 103 | multi 104 | line 105 | stacktrace 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | multi 115 | line 116 | stacktrace 117 | 118 | 119 | 120 | """.trimIndent() + "\n" 121 | expected = normalizeLinefeed(expected) 122 | val actual = outputFile.readText() 123 | assertThat(actual).isEqualTo(expected) 124 | } 125 | 126 | it("emits completion") { 127 | subscriber.assertCompleted() 128 | } 129 | 130 | it("does not emit values") { 131 | subscriber.assertNoValues() 132 | } 133 | 134 | it("does not emit error") { 135 | subscriber.assertNoErrors() 136 | } 137 | } 138 | }) 139 | -------------------------------------------------------------------------------- /composer/src/test/kotlin/com/gojuno/composer/ArgsSpec.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.assertj.core.api.Assertions.assertThatThrownBy 5 | import org.jetbrains.spek.api.Spek 6 | import org.jetbrains.spek.api.dsl.context 7 | import org.jetbrains.spek.api.dsl.it 8 | 9 | class ArgsSpec : Spek({ 10 | 11 | val rawArgsWithOnlyRequiredFields = arrayOf( 12 | "--apk", "apk_path", 13 | "--test-apk", "test_apk_path" 14 | ) 15 | 16 | context("parse args with only required params") { 17 | 18 | val args by memoized { parseArgs(rawArgsWithOnlyRequiredFields) } 19 | 20 | it("parses passes instrumentationArguments and uses default values for other fields") { 21 | assertThat(args).isEqualTo(Args( 22 | appApkPath = "apk_path", 23 | testApkPath = "test_apk_path", 24 | testRunner = "", 25 | shard = true, 26 | outputDirectory = "composer-output", 27 | instrumentationArguments = emptyList(), 28 | verboseOutput = false, 29 | keepOutputOnExit = false, 30 | devices = emptyList(), 31 | devicePattern = "", 32 | installTimeoutSeconds = 120, 33 | failIfNoTests = true, 34 | runWithOrchestrator = false, 35 | extraApks = emptyList() 36 | )) 37 | } 38 | } 39 | 40 | context("parse args with test runner specified") { 41 | 42 | val args by memoized { 43 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--test-runner", "test_runner")) 44 | } 45 | 46 | it("converts instrumentation arguments to list of key-value pairs") { 47 | assertThat(args.testRunner).isEqualTo("test_runner") 48 | } 49 | } 50 | 51 | context("parse args with instrumentation arguments") { 52 | 53 | val args by memoized { 54 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--instrumentation-arguments", "key1", "value1", "key2", "value2")) 55 | } 56 | 57 | it("converts instrumentation arguments to list of key-value pairs") { 58 | assertThat(args.instrumentationArguments).isEqualTo(listOf("key1", "value1", "key2", "value2")) 59 | } 60 | } 61 | 62 | context("parse args with instrumentation arguments with values with commas") { 63 | 64 | val args by memoized { 65 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--instrumentation-arguments", "key1", "value1,value2", "key2", "value3,value4")) 66 | } 67 | 68 | it("converts instrumentation arguments to list of key-value pairs") { 69 | assertThat(args.instrumentationArguments).isEqualTo(listOf("key1", "value1,value2", "key2", "value3,value4")) 70 | } 71 | } 72 | 73 | context("parse args with explicitly passed --shard") { 74 | 75 | listOf(true, false).forEach { shard -> 76 | 77 | context("--shard $shard") { 78 | 79 | val args by memoized { 80 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--shard", "$shard")) 81 | } 82 | 83 | it("parses --shard correctly") { 84 | assertThat(args.shard).isEqualTo(shard) 85 | } 86 | } 87 | } 88 | } 89 | 90 | context("parse args with explicitly passed --verbose-output") { 91 | 92 | listOf(true, false).forEach { verboseOutput -> 93 | 94 | context("--verbose--output $verboseOutput") { 95 | 96 | val args by memoized { 97 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--verbose-output", "$verboseOutput")) 98 | } 99 | 100 | it("parses --verbose-output correctly") { 101 | assertThat(args.verboseOutput).isEqualTo(verboseOutput) 102 | } 103 | } 104 | } 105 | } 106 | 107 | context("parse args with passed --devices") { 108 | 109 | val args by memoized { 110 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--devices", "emulator-5554")) 111 | } 112 | 113 | it("parses correctly device ids") { 114 | assertThat(args.devices).isEqualTo(listOf("emulator-5554")) 115 | } 116 | } 117 | 118 | context("parse args with passed two --devices") { 119 | 120 | val args by memoized { 121 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--devices", "emulator-5554", "emulator-5556")) 122 | } 123 | 124 | it("parses correctly two device ids") { 125 | assertThat(args.devices).isEqualTo(listOf("emulator-5554", "emulator-5556")) 126 | } 127 | } 128 | 129 | context("parse args with passed --device-pattern") { 130 | 131 | val args by memoized { 132 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--device-pattern", "[abc|def]")) 133 | } 134 | 135 | it("parses correctly device-pattern") { 136 | assertThat(args.devicePattern).isEqualTo("[abc|def]") 137 | } 138 | } 139 | 140 | context("parse args with passed --devices and --device-pattern") { 141 | 142 | it("raises argument error") { 143 | assertThatThrownBy { parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--device-pattern", "[abc|def]") + arrayOf("--devices", "emulator-5554")) } 144 | .isInstanceOf(IllegalArgumentException::class.java) 145 | .hasMessageContaining("Specifying both --devices and --device-pattern is prohibited.") 146 | } 147 | } 148 | 149 | context("parse args with --keep-output-on-exit") { 150 | 151 | val args by memoized { 152 | parseArgs(rawArgsWithOnlyRequiredFields + "--keep-output-on-exit") 153 | } 154 | 155 | it("parses --keep-output-on-exit correctly") { 156 | assertThat(args.keepOutputOnExit).isEqualTo(true) 157 | } 158 | } 159 | 160 | context("parse args with passed --install-timeout") { 161 | 162 | val args by memoized { 163 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--install-timeout", "600")) 164 | } 165 | 166 | it("parses --install-timeout correctly") { 167 | assertThat(args.installTimeoutSeconds).isEqualTo(600) 168 | } 169 | } 170 | 171 | context("parse args with passed --fail-if-no-tests") { 172 | 173 | val args by memoized { 174 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--fail-if-no-tests", "false")) 175 | } 176 | 177 | it("parses --fail-if-no-tests correctly") { 178 | assertThat(args.failIfNoTests).isEqualTo(false) 179 | } 180 | } 181 | 182 | context("parse args with explicitly passed --fail-if-no-tests") { 183 | 184 | listOf(true, false).forEach { failIfNoTests -> 185 | 186 | context("--fail-if-no-tests $failIfNoTests") { 187 | 188 | val args by memoized { 189 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--fail-if-no-tests", "$failIfNoTests")) 190 | } 191 | 192 | it("parses --fail-if-no-tests correctly") { 193 | assertThat(args.failIfNoTests).isEqualTo(failIfNoTests) 194 | } 195 | } 196 | } 197 | } 198 | 199 | context("parse args with --with-orchestrator") { 200 | 201 | val args by memoized { 202 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--with-orchestrator", "true")) 203 | } 204 | 205 | it("parses --with-orchestrator correctly") { 206 | assertThat(args.runWithOrchestrator).isEqualTo(true) 207 | } 208 | } 209 | 210 | context("parse args with passed --extra-apks") { 211 | 212 | val args by memoized { 213 | parseArgs(rawArgsWithOnlyRequiredFields + arrayOf("--extra-apks", "apk1.apk", "apk2.apk")) 214 | } 215 | 216 | it("parses correctly two extra apks") { 217 | assertThat(args.extraApks).isEqualTo(listOf("apk1.apk", "apk2.apk")) 218 | } 219 | } 220 | 221 | }) 222 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/Instrumentation.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import com.gojuno.composer.InstrumentationTest.Status.Failed 4 | import com.gojuno.composer.InstrumentationTest.Status.Ignored 5 | import com.gojuno.composer.InstrumentationTest.Status.Passed 6 | import rx.Observable 7 | import java.io.File 8 | 9 | data class InstrumentationTest( 10 | val index: Int, 11 | val total: Int, 12 | val className: String, 13 | val testName: String, 14 | val status: Status, 15 | val durationNanos: Long 16 | ) { 17 | 18 | sealed class Status { 19 | object Passed : Status() 20 | data class Ignored(val stacktrace: String = "") : Status() 21 | data class Failed(val stacktrace: String) : Status() 22 | } 23 | } 24 | 25 | /** 26 | * @see android.support.test.internal.runner.listener.InstrumentationResultPrinter 27 | */ 28 | enum class StatusCode(val code: Int) { 29 | Start(1), 30 | Ok(0), 31 | Failure(-2), 32 | Ignored(-3), 33 | AssumptionFailure(-4) 34 | } 35 | 36 | data class InstrumentationEntry( 37 | val numTests: Int, 38 | val stream: String, 39 | val id: String, 40 | val test: String, 41 | val clazz: String, 42 | val current: Int, 43 | val stack: String, 44 | val statusCode: StatusCode, 45 | val timestampNanos: Long 46 | ) 47 | 48 | private fun String.substringBetween(first: String, second: String): String { 49 | val indexOfFirst = indexOf(first) 50 | 51 | if (indexOfFirst < 0) { 52 | return "" 53 | } 54 | 55 | val startIndex = indexOfFirst + first.length 56 | val endIndex = indexOf(second, startIndex).let { if (it <= 0) length else it } 57 | 58 | return substring(startIndex, endIndex) 59 | } 60 | 61 | private fun String.parseInstrumentationStatusValue(key: String): String = this 62 | .substringBetween("INSTRUMENTATION_STATUS: $key=", "INSTRUMENTATION_STATUS") 63 | .trim() 64 | 65 | private fun String.throwIfError(output: File) = when { 66 | contains("INSTRUMENTATION_RESULT: shortMsg=") -> { 67 | throw Exception("Application process crashed. Check Logcat output for more details.") 68 | } 69 | 70 | contains("INSTRUMENTATION_STATUS: Error=Unable to find instrumentation info for") -> { 71 | val runner = substringBetween("ComponentInfo{", "}").substringAfter("/") 72 | throw Exception( 73 | "Instrumentation was unable to run tests using runner $runner.\n" + 74 | "Most likely you forgot to declare test runner in AndroidManifest.xml or build.gradle.\n" + 75 | "Detailed log can be found in ${output.path} or Logcat output.\n" + 76 | "See https://github.com/gojuno/composer/issues/79 for more info." 77 | ) 78 | } 79 | 80 | else -> this 81 | } 82 | 83 | private fun parseInstrumentationEntry(str: String): InstrumentationEntry = 84 | InstrumentationEntry( 85 | numTests = str.parseInstrumentationStatusValue("numtests").toInt(), 86 | stream = str.parseInstrumentationStatusValue("stream"), 87 | stack = str.parseInstrumentationStatusValue("stack"), 88 | id = str.parseInstrumentationStatusValue("id"), 89 | test = str.parseInstrumentationStatusValue("test"), 90 | clazz = str.parseInstrumentationStatusValue("class"), 91 | current = str.parseInstrumentationStatusValue("current").toInt(), 92 | statusCode = str.substringBetween("INSTRUMENTATION_STATUS_CODE: ", "INSTRUMENTATION_STATUS") 93 | .trim() 94 | .toInt() 95 | .let { code -> 96 | StatusCode.values().firstOrNull { it.code == code } 97 | } 98 | .let { statusCode -> 99 | when (statusCode) { 100 | null -> throw IllegalStateException("Unknown test status code [$statusCode], please report that to Composer maintainers $str") 101 | else -> statusCode 102 | } 103 | }, 104 | timestampNanos = System.nanoTime() 105 | ) 106 | 107 | // Reads stream in "tail -f" mode. 108 | fun readInstrumentationOutput(output: File): Observable { 109 | data class Result(val buffer: String = "", val readyForProcessing: Boolean = false) 110 | 111 | return tail(output) 112 | .map(String::trim) 113 | .map { it.throwIfError(output) } 114 | .takeWhile { 115 | // `INSTRUMENTATION_CODE: ` is the last line printed by instrumentation, even if 0 tests were run. 116 | !it.startsWith("INSTRUMENTATION_CODE") 117 | } 118 | .scan(Result()) { previousResult, newLine -> 119 | val buffer = when (previousResult.readyForProcessing) { 120 | true -> newLine 121 | false -> "${previousResult.buffer}${System.lineSeparator()}$newLine" 122 | } 123 | 124 | Result(buffer = buffer, readyForProcessing = newLine.startsWith("INSTRUMENTATION_STATUS_CODE")) 125 | } 126 | .filter { it.readyForProcessing } 127 | .map { it.buffer } 128 | .map(::parseInstrumentationEntry) 129 | } 130 | 131 | fun Observable.asTests(): Observable { 132 | data class Result(val entries: List = emptyList(), val tests: List = emptyList(), val totalTestsCount: Int = 0) 133 | 134 | return this 135 | .scan(Result()) { previousResult, newEntry -> 136 | val entries = previousResult.entries + newEntry 137 | val tests: List = entries 138 | .mapIndexed { index, first -> 139 | val second = entries 140 | .subList(index + 1, entries.size) 141 | .firstOrNull { 142 | first.clazz == it.clazz && 143 | first.test == it.test && 144 | first.current == it.current && 145 | first.statusCode != it.statusCode 146 | } 147 | 148 | if (second == null) null else first to second 149 | } 150 | .filterNotNull() 151 | .map { (first, second) -> 152 | InstrumentationTest( 153 | index = first.current, 154 | total = first.numTests, 155 | className = first.clazz, 156 | testName = first.test, 157 | status = when (second.statusCode) { 158 | StatusCode.Ok -> Passed 159 | StatusCode.Ignored -> Ignored() 160 | StatusCode.AssumptionFailure -> Ignored(stacktrace = second.stack) 161 | StatusCode.Failure -> Failed(stacktrace = second.stack) 162 | StatusCode.Start -> throw IllegalStateException( 163 | "Unexpected status code [Start] in second entry, " + 164 | "please report that to Composer maintainers ($first, $second)" 165 | ) 166 | }, 167 | durationNanos = second.timestampNanos - first.timestampNanos 168 | ) 169 | } 170 | 171 | Result( 172 | entries = entries.filter { entry -> tests.firstOrNull { it.className == entry.clazz && it.testName == entry.test } == null }, 173 | tests = tests, 174 | totalTestsCount = previousResult.totalTestsCount + tests.size 175 | ) 176 | } 177 | .takeUntil { 178 | if (it.entries.count { it.current == it.numTests } == 2) { 179 | if (it.totalTestsCount < it.entries.first().numTests) { 180 | throw IllegalStateException("Less tests were emitted than Instrumentation reported: $it") 181 | } 182 | 183 | true 184 | } else { 185 | false 186 | } 187 | } 188 | .filter { it.tests.isNotEmpty() } 189 | .flatMap { Observable.from(it.tests) } 190 | } 191 | -------------------------------------------------------------------------------- /html-report/styles/_elements.scss: -------------------------------------------------------------------------------- 1 | /** 2 | Card block 3 | Card with info 4 | Buttons 5 | Buttons group 6 | Dropdown 7 | Titles 8 | Text elements 9 | Dropdown 10 | Notifications 11 | Tooltip 12 | Title + text block 13 | Labels 14 | Icons 15 | **/ 16 | 17 | /* Card */ 18 | .card { 19 | margin-bottom: $margin; 20 | padding: $side-padding; 21 | border-radius: 4px; 22 | background: #fff; 23 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); 24 | } 25 | 26 | @media $large-screen { 27 | .card { 28 | padding: $side-padding-desktop; 29 | } 30 | } 31 | 32 | /* Card with info */ 33 | 34 | .card-info { 35 | margin-right: $side-padding; 36 | flex-grow: 1; 37 | } 38 | 39 | .card-info__title { 40 | @extend .text-sub-title-light; 41 | } 42 | 43 | .card-info__content { 44 | margin-top: 15px; 45 | font-size: 40px; 46 | line-height: 1; 47 | color: $electric; 48 | font-weight: 300; 49 | } 50 | 51 | /* Buttons */ 52 | $button-inner-shadow: inset 0 2px 1px 0 rgba(0, 0, 0, 0.1); 53 | $button-inner-shadow-darker: inset 0 2px 1px 0 rgba(0, 0, 0, 0.1); 54 | $button-height: 34px; 55 | 56 | .button { 57 | display: inline-block; 58 | box-sizing: border-box; 59 | height: $button-height; 60 | padding: 0 12px; 61 | border-radius: 4px; 62 | border: none; 63 | font-size: $font-normal; 64 | font-weight: 500; 65 | line-height: $button-height; 66 | text-align: center; 67 | background-color: $purple; 68 | color: $white; 69 | outline: none; 70 | cursor: pointer; 71 | transition: background $short-transition; 72 | 73 | &:hover:not(.disabled), &:hover:not([disabled]) { 74 | background-color: $purple-darker; 75 | } 76 | 77 | &:active:not(.disabled), &:active:not([disabled]) { 78 | background-color: $purple-darker; 79 | } 80 | 81 | &:hover { 82 | color: $white; 83 | } 84 | 85 | &.small { 86 | min-width: 80px; 87 | height: 24px; 88 | font-size: $font-small; 89 | padding: 2px 12px 0; 90 | } 91 | 92 | &.full { 93 | width: 100%; 94 | } 95 | 96 | &.fixed { 97 | min-width: 90px; 98 | } 99 | 100 | &.disabled, &[disabled] { 101 | opacity: .5; 102 | cursor: default; 103 | } 104 | 105 | &.flat { 106 | border-radius: 0; 107 | } 108 | 109 | &.success { 110 | background-color: $green; 111 | border-color: $green; 112 | color: $white; 113 | 114 | &:hover:not(.disabled), &:hover:not([disabled]) { 115 | background-color: $green-darker; 116 | } 117 | 118 | &:active:not(.disabled), &:active:not([disabled]) { 119 | background-color: $green-darker; 120 | } 121 | } 122 | 123 | &.alert { 124 | background-color: $red; 125 | color: $white; 126 | 127 | &:hover:not(.disabled), &:hover:not([disabled]) { 128 | background-color: $red-darker; 129 | } 130 | 131 | &:active:not(.disabled), &:active:not([disabled]) { 132 | background-color: $red-darker; 133 | } 134 | } 135 | 136 | &.warning { 137 | background-color: $yellow; 138 | color: $white; 139 | 140 | &:hover:not(.disabled), &:hover:not([disabled]) { 141 | background-color: $yellow-darker; 142 | } 143 | 144 | &:active:not(.disabled), &:active:not([disabled]) { 145 | background-color: $yellow-darker; 146 | } 147 | } 148 | 149 | &.secondary { 150 | background-color: $light-grey; 151 | color: $primary-blue; 152 | 153 | &:hover:not(.disabled), &:hover:not([disabled]) { 154 | background-color: $light-grey-darker; 155 | } 156 | 157 | &:active:not(.disabled), &:active:not([disabled]) { 158 | background-color: $light-grey-darker; 159 | } 160 | } 161 | } 162 | 163 | /* Buttons group */ 164 | 165 | .button-group { 166 | .button { 167 | margin-right: 20px; 168 | } 169 | } 170 | 171 | /* Titles */ 172 | 173 | .title-common { 174 | margin-bottom: 32px; 175 | @extend .text-title; 176 | } 177 | 178 | .title-common-simple { 179 | @extend .text-title; 180 | text-transform: uppercase; 181 | } 182 | 183 | /* Text elements */ 184 | 185 | .emphasized { 186 | @extend .text-sub-title; 187 | } 188 | 189 | .italic { 190 | font-style: italic; 191 | } 192 | 193 | .success, 194 | // deprecated 195 | .successful { 196 | color: $green; 197 | } 198 | 199 | .warning { 200 | color: $yellow; 201 | } 202 | 203 | .danger, 204 | // deprecated 205 | .failure { 206 | color: $red; 207 | } 208 | 209 | .fair { 210 | color: $primary-grey; 211 | } 212 | 213 | .bold { 214 | font-weight: bold; 215 | } 216 | 217 | .align-center { 218 | text-align: center; 219 | } 220 | 221 | .valign { 222 | vertical-align: middle; 223 | } 224 | 225 | .nowrap { 226 | white-space: nowrap; 227 | } 228 | 229 | .break-word { 230 | overflow-wrap: break-word; 231 | word-wrap: break-word; 232 | word-break: break-word; 233 | } 234 | 235 | .small { 236 | @extend .text-note; 237 | color: $primary-grey; 238 | } 239 | 240 | .grey { 241 | color: $primary-grey; 242 | } 243 | 244 | /* Dropdown */ 245 | 246 | .dropdown.button { 247 | position: relative; 248 | padding-right: 36px; 249 | 250 | &:after { 251 | content: ''; 252 | position: absolute; 253 | display: block; 254 | top: 13px; 255 | right: 16px; 256 | width: 0; 257 | height: 0; 258 | border-top: 4px solid $white; 259 | border-left: 4px solid transparent; 260 | border-right: 4px solid transparent; 261 | } 262 | } 263 | 264 | .dropdown-list { 265 | display: inline-block; 266 | position: relative; 267 | } 268 | 269 | .dropdown-list__items { 270 | position: absolute; 271 | top: 100%; 272 | left: 0; 273 | width: 200px; 274 | max-height: 0; 275 | margin-top: 8px; 276 | overflow: hidden; 277 | border-radius: 4px; 278 | opacity: 0; 279 | background: $purple-lighter; 280 | box-shadow: 0 3px 0 0 rgba(0, 0, 0, 0.15); 281 | transform: translateY(-15px); 282 | transition-property: opacity, transform; 283 | transition-duration: .3s; 284 | 285 | &.open { 286 | max-height: 1000px; 287 | opacity: 1; 288 | transform: translateY(0); 289 | z-index: 2; 290 | } 291 | 292 | &.left-side { 293 | left: auto; 294 | right: 0; 295 | } 296 | } 297 | 298 | .dropdown-list__item { 299 | &:first-child { 300 | padding-top: 7px; 301 | } 302 | 303 | &:last-child { 304 | padding-bottom: 7px; 305 | } 306 | } 307 | 308 | .dropdown-list__item__i { 309 | display: block; 310 | padding: 9px 20px; 311 | color: $white; 312 | 313 | &:hover, &:active { 314 | color: $white; 315 | background: $purple; 316 | } 317 | } 318 | 319 | /* Notifications */ 320 | 321 | .notification__close { 322 | position: absolute; 323 | top: 2px; 324 | right: 7px; 325 | opacity: .2; 326 | font-size: 20px; 327 | line-height: 1; 328 | font-weight: bold; 329 | color: #000; 330 | text-shadow: 0 1px 0 #fff; 331 | cursor: pointer; 332 | 333 | &:hover { 334 | opacity: .5; 335 | } 336 | } 337 | 338 | .notification { 339 | position: relative; 340 | padding: 20px 35px 17px 15px; 341 | margin-bottom: 20px; 342 | border-radius: 4px; 343 | @extend .text-note; 344 | color: $white; 345 | background-color: $electric; 346 | 347 | &.success { 348 | background-color: $green; 349 | } 350 | 351 | &.warning { 352 | background-color: $yellow; 353 | } 354 | 355 | &.danger { 356 | color: $red; 357 | background-color: $system-red; 358 | } 359 | 360 | &.inline { 361 | margin: 0; 362 | display: inline-block; 363 | } 364 | } 365 | 366 | /* Tooltip */ 367 | 368 | .tooltip { 369 | position: relative; 370 | } 371 | 372 | .tooltip__handler { 373 | cursor: default; 374 | 375 | &:hover { 376 | .tooltip__content { 377 | opacity: 1; 378 | pointer-events: auto; 379 | transform: translateY(0px); 380 | z-index: 10; 381 | } 382 | } 383 | } 384 | 385 | .tooltip__content { 386 | position: absolute; 387 | z-index: 2; 388 | margin-top: 5px; 389 | padding: 12px 16px 10px; 390 | opacity: 0; 391 | border-radius: 4px; 392 | @extend .text-regular; 393 | color: $primary-blue; 394 | background: $light-grey; 395 | box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.15); 396 | pointer-events: none; 397 | transform: translateY(10px); 398 | transition: all .25s ease-out; 399 | 400 | &.revert { 401 | right: 100%; 402 | margin-right: -18px; 403 | 404 | &:after { 405 | left: auto; 406 | right: 25px; 407 | } 408 | } 409 | 410 | &:after { 411 | content: ''; 412 | position: absolute; 413 | top: -7px; 414 | left: 25px; 415 | height: 0; 416 | width: 0; 417 | border-left: solid transparent 6px; 418 | border-right: solid transparent 6px; 419 | border-bottom: solid $light-grey 7px; 420 | } 421 | 422 | & > div { 423 | display: table; 424 | white-space: nowrap; 425 | } 426 | } 427 | 428 | /* Title + Text block */ 429 | 430 | .text-block { 431 | margin: 0 $margin 24px 0; 432 | } 433 | 434 | .text-block__title { 435 | margin-bottom: 10px; 436 | @extend .text-sub-title-light; 437 | 438 | &.emphasized { 439 | color: $primary-blue; 440 | } 441 | } 442 | 443 | .text-block__content { 444 | min-height: 30px; 445 | 446 | [type='text'], 447 | [type='password'], 448 | [type='email'], 449 | [type='tel'], 450 | textarea, 451 | select { 452 | position: relative; 453 | top: -6px; 454 | } 455 | } 456 | 457 | /* Labels */ 458 | 459 | .label { 460 | display: inline-block; 461 | box-sizing: border-box; 462 | height: 20px; 463 | padding: 4px 10px 0; 464 | border-radius: 20px; 465 | font-size: $font-small; 466 | line-height: 1; 467 | font-weight: bold; 468 | white-space: nowrap; 469 | vertical-align: middle; 470 | text-transform: capitalize; 471 | text-align: center; 472 | color: #fff; 473 | background-color: $primary-grey; 474 | 475 | &.info { 476 | background-color: $electric; 477 | } 478 | 479 | &.success { 480 | background-color: $green; 481 | } 482 | 483 | &.danger, 484 | // deprecated 485 | &.alert { 486 | background-color: $red; 487 | } 488 | 489 | &.warning { 490 | background-color: $yellow; 491 | } 492 | 493 | &.error { 494 | color: $red; 495 | background-color: $system-red; 496 | } 497 | 498 | &.wide { 499 | min-width: 70px; 500 | } 501 | 502 | &.offset { 503 | position: relative; 504 | top: -2px; 505 | margin: 0 8px; 506 | text-transform: capitalize; 507 | } 508 | 509 | &.inlined { 510 | margin-right: 5px; 511 | } 512 | } 513 | 514 | /* Icons */ 515 | 516 | .icon { 517 | display: inline-block; 518 | 519 | &.expand { 520 | width: 7px; 521 | height: 7px; 522 | border-left: 1px solid $primary-blue; 523 | border-top: 1px solid $primary-blue; 524 | transform: rotate(-135deg) 525 | } 526 | } 527 | -------------------------------------------------------------------------------- /composer/src/test/resources/instrumentation-output-failed-test.txt: -------------------------------------------------------------------------------- 1 | adb shell am instrument -w -r -e numShards 20 -e shardIndex 1 com.example.test/android.support.test.runner.JunoAndroidRunner 2 | INSTRUMENTATION_STATUS: numtests=4 3 | INSTRUMENTATION_STATUS: stream= 4 | com.example.test.TestClass: 5 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 6 | INSTRUMENTATION_STATUS: test=test1 7 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 8 | INSTRUMENTATION_STATUS: current=1 9 | INSTRUMENTATION_STATUS_CODE: 1 10 | INSTRUMENTATION_STATUS: numtests=4 11 | INSTRUMENTATION_STATUS: stream= 12 | Error in test1(com.example.test.TestClass): 13 | java.net.UnknownHostException: Test Exception 14 | at com.example.test.TestClass.test1.1.invoke(TestClass.kt:245) 15 | at com.example.test.TestClass.test1.1.invoke(TestClass.kt:44) 16 | at com.example.test.TestClass.test1(TestClass.kt:238) 17 | at java.lang.reflect.Method.invoke(Native Method) 18 | at org.junit.runners.model.FrameworkMethod.1.runReflectiveCall(FrameworkMethod.java:50) 19 | at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) 20 | at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) 21 | at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) 22 | at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26) 23 | at org.junit.rules.ExpectedException.ExpectedExceptionStatement.evaluate(ExpectedException.java:239) 24 | at com.example.test.utils.LaunchAppRule.apply.1.evaluate(LaunchAppRule.kt:36) 25 | at com.example.test.utils.RetryRule.runTest(RetryRule.kt:43) 26 | at com.example.test.utils.RetryRule.access.runTest(RetryRule.kt:14) 27 | at com.example.test.utils.RetryRule.apply.1.evaluate(RetryRule.kt:29) 28 | at org.junit.rules.RunRules.evaluate(RunRules.java:20) 29 | at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) 30 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) 31 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) 32 | at org.junit.runners.ParentRunner.3.run(ParentRunner.java:290) 33 | at org.junit.runners.ParentRunner.1.schedule(ParentRunner.java:71) 34 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 35 | at org.junit.runners.ParentRunner.access.000(ParentRunner.java:58) 36 | at org.junit.runners.ParentRunner.2.evaluate(ParentRunner.java:268) 37 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 38 | at org.junit.runners.Suite.runChild(Suite.java:128) 39 | at org.junit.runners.Suite.runChild(Suite.java:27) 40 | at org.junit.runners.ParentRunner.3.run(ParentRunner.java:290) 41 | at org.junit.runners.ParentRunner.1.schedule(ParentRunner.java:71) 42 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 43 | at org.junit.runners.ParentRunner.access.000(ParentRunner.java:58) 44 | at org.junit.runners.ParentRunner.2.evaluate(ParentRunner.java:268) 45 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 46 | at org.junit.runner.JUnitCore.run(JUnitCore.java:137) 47 | at org.junit.runner.JUnitCore.run(JUnitCore.java:115) 48 | at android.support.test.internal.runner.TestExecutor.execute(TestExecutor.java:59) 49 | at android.support.test.runner.JunoAndroidRunner.onStart(JunoAndroidRunner.kt:107) 50 | at android.app.Instrumentation.InstrumentationThread.run(Instrumentation.java:1932) 51 | 52 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 53 | INSTRUMENTATION_STATUS: test=test1 54 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 55 | INSTRUMENTATION_STATUS: stack=java.net.UnknownHostException: Test Exception 56 | at com.example.test.TestClass.test1.1.invoke(TestClass.kt:245) 57 | at com.example.test.TestClass.test1.1.invoke(TestClass.kt:44) 58 | at com.example.test.TestClass.test1(TestClass.kt:238) 59 | at java.lang.reflect.Method.invoke(Native Method) 60 | at org.junit.runners.model.FrameworkMethod.1.runReflectiveCall(FrameworkMethod.java:50) 61 | at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) 62 | at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) 63 | at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) 64 | at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26) 65 | at org.junit.rules.ExpectedException.ExpectedExceptionStatement.evaluate(ExpectedException.java:239) 66 | at com.example.test.utils.LaunchAppRule.apply.1.evaluate(LaunchAppRule.kt:36) 67 | at com.example.test.utils.RetryRule.runTest(RetryRule.kt:43) 68 | at com.example.test.utils.RetryRule.access.runTest(RetryRule.kt:14) 69 | at com.example.test.utils.RetryRule.apply.1.evaluate(RetryRule.kt:29) 70 | at org.junit.rules.RunRules.evaluate(RunRules.java:20) 71 | at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) 72 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) 73 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) 74 | at org.junit.runners.ParentRunner.3.run(ParentRunner.java:290) 75 | at org.junit.runners.ParentRunner.1.schedule(ParentRunner.java:71) 76 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 77 | at org.junit.runners.ParentRunner.access.000(ParentRunner.java:58) 78 | at org.junit.runners.ParentRunner.2.evaluate(ParentRunner.java:268) 79 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 80 | at org.junit.runners.Suite.runChild(Suite.java:128) 81 | at org.junit.runners.Suite.runChild(Suite.java:27) 82 | at org.junit.runners.ParentRunner.3.run(ParentRunner.java:290) 83 | at org.junit.runners.ParentRunner.1.schedule(ParentRunner.java:71) 84 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 85 | at org.junit.runners.ParentRunner.access.000(ParentRunner.java:58) 86 | at org.junit.runners.ParentRunner.2.evaluate(ParentRunner.java:268) 87 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 88 | at org.junit.runner.JUnitCore.run(JUnitCore.java:137) 89 | at org.junit.runner.JUnitCore.run(JUnitCore.java:115) 90 | at android.support.test.internal.runner.TestExecutor.execute(TestExecutor.java:59) 91 | at android.support.test.runner.JunoAndroidRunner.onStart(JunoAndroidRunner.kt:107) 92 | at android.app.Instrumentation.InstrumentationThread.run(Instrumentation.java:1932) 93 | 94 | INSTRUMENTATION_STATUS: current=1 95 | INSTRUMENTATION_STATUS_CODE: -2 96 | INSTRUMENTATION_STATUS: numtests=4 97 | INSTRUMENTATION_STATUS: stream= 98 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 99 | INSTRUMENTATION_STATUS: test=test2 100 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 101 | INSTRUMENTATION_STATUS: current=2 102 | INSTRUMENTATION_STATUS_CODE: 1 103 | INSTRUMENTATION_STATUS: numtests=4 104 | INSTRUMENTATION_STATUS: stream=. 105 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 106 | INSTRUMENTATION_STATUS: test=test2 107 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 108 | INSTRUMENTATION_STATUS: current=2 109 | INSTRUMENTATION_STATUS_CODE: 0 110 | INSTRUMENTATION_STATUS: numtests=4 111 | INSTRUMENTATION_STATUS: stream= 112 | com.example.test.TestClass: 113 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 114 | INSTRUMENTATION_STATUS: test=test3 115 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 116 | INSTRUMENTATION_STATUS: current=3 117 | INSTRUMENTATION_STATUS_CODE: 1 118 | INSTRUMENTATION_STATUS: numtests=4 119 | INSTRUMENTATION_STATUS: stream=. 120 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 121 | INSTRUMENTATION_STATUS: test=test3 122 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 123 | INSTRUMENTATION_STATUS: current=3 124 | INSTRUMENTATION_STATUS_CODE: 0 125 | INSTRUMENTATION_STATUS: numtests=4 126 | INSTRUMENTATION_STATUS: stream= 127 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 128 | INSTRUMENTATION_STATUS: test=test4 129 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 130 | INSTRUMENTATION_STATUS: current=4 131 | INSTRUMENTATION_STATUS_CODE: 1 132 | INSTRUMENTATION_STATUS: numtests=4 133 | INSTRUMENTATION_STATUS: stream=. 134 | INSTRUMENTATION_STATUS: id=AndroidJUnitRunner 135 | INSTRUMENTATION_STATUS: test=test4 136 | INSTRUMENTATION_STATUS: class=com.example.test.TestClass 137 | INSTRUMENTATION_STATUS: current=4 138 | INSTRUMENTATION_STATUS_CODE: 0 139 | 140 | Time: 96.641 141 | There was 1 failure: 142 | 1) test1(com.example.test.TestClass) 143 | java.net.UnknownHostException: Test Exception 144 | at com.example.test.TestClass.test1.1.invoke(TestClass.kt:245) 145 | at com.example.test.TestClass.test1.1.invoke(TestClass.kt:44) 146 | at com.example.test.screens.AddCreditCardScreen.Companion.invoke(AddCreditCardScreen.kt:23) 147 | at com.example.test.TestClass.test1(TestClass.kt:238) 148 | at java.lang.reflect.Method.invoke(Native Method) 149 | at org.junit.runners.model.FrameworkMethod.1.runReflectiveCall(FrameworkMethod.java:50) 150 | at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) 151 | at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47) 152 | at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) 153 | at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26) 154 | at org.junit.rules.ExpectedException.ExpectedExceptionStatement.evaluate(ExpectedException.java:239) 155 | at com.example.test.utils.LaunchAppRule.apply.1.evaluate(LaunchAppRule.kt:36) 156 | at com.example.test.utils.RetryRule.runTest(RetryRule.kt:43) 157 | at com.example.test.utils.RetryRule.access.runTest(RetryRule.kt:14) 158 | at com.example.test.utils.RetryRule.apply.1.evaluate(RetryRule.kt:29) 159 | at org.junit.rules.RunRules.evaluate(RunRules.java:20) 160 | at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325) 161 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78) 162 | at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57) 163 | at org.junit.runners.ParentRunner.3.run(ParentRunner.java:290) 164 | at org.junit.runners.ParentRunner.1.schedule(ParentRunner.java:71) 165 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 166 | at org.junit.runners.ParentRunner.access.000(ParentRunner.java:58) 167 | at org.junit.runners.ParentRunner.2.evaluate(ParentRunner.java:268) 168 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 169 | at org.junit.runners.Suite.runChild(Suite.java:128) 170 | at org.junit.runners.Suite.runChild(Suite.java:27) 171 | at org.junit.runners.ParentRunner.3.run(ParentRunner.java:290) 172 | at org.junit.runners.ParentRunner.1.schedule(ParentRunner.java:71) 173 | at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288) 174 | at org.junit.runners.ParentRunner.access.000(ParentRunner.java:58) 175 | at org.junit.runners.ParentRunner.2.evaluate(ParentRunner.java:268) 176 | at org.junit.runners.ParentRunner.run(ParentRunner.java:363) 177 | at org.junit.runner.JUnitCore.run(JUnitCore.java:137) 178 | at org.junit.runner.JUnitCore.run(JUnitCore.java:115) 179 | at android.support.test.internal.runner.TestExecutor.execute(TestExecutor.java:59) 180 | at android.support.test.runner.JunoAndroidRunner.onStart(JunoAndroidRunner.kt:107) 181 | at android.app.Instrumentation.InstrumentationThread.run(Instrumentation.java:1932) 182 | 183 | FAILURES!!! 184 | Tests run: 4, Failures: 1 185 | 186 | 187 | INSTRUMENTATION_CODE: -1 188 | 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Composer — Reactive Android Instrumentation Test Runner. 2 | 3 | Composer is a modern reactive replacement for [square/spoon][spoon] with following feature set: 4 | 5 | * Parallel test execution on multiple emulators/devices with [test sharding][test sharding] support. 6 | * Logcat output capturing per test and for whole test run as well. 7 | * Screenshots and files pulling for each test reactively (with support for [square/spoon][spoon] folder structure). 8 | * JUnit4 report generation. 9 | 10 | ![Demo](demo/composer.gif) 11 | 12 | ### Table of Contents 13 | 14 | - [Why we've decided to replace square/spoon](#why-weve-decided-to-replace-squarespoon) 15 | - [HTML Report](#html-report) 16 | - [Usage](#usage) 17 | - [Download](#download) 18 | - [3rd-party Composer Gradle Plugin](#3rd-party-composer-gradle-plugin) 19 | - [Swarmer](#swarmer) 20 | - [How to build](#how-to-build) 21 | - [License](#license) 22 | 23 | ### Why we've decided to replace [square/spoon][spoon] 24 | 25 | **Problem 1:** Our UI tests are stable, but we saw a lot of UI tests build failures. About ~50% of our CI builds were failing. All such failures of UI tests came from Spoon not being able to run tests on one or more emulators (device is red in the report and error message is `…work/emulator-5554/result.json (No such file or directory)`, basically it timed out on installing the apk on a device, increasing adb timeout did not help, all emulators responded to adb commands and mouse/keyboard interactions, we suppose problem is in in ddmlib used by Spoon. 26 | 27 | **Solution:** Composer does not use ddmlib and talks to emulators/devices by invoking `adb` binary. 28 | 29 | **Problem 2:** Pretty often when test run finished, Spoon freezed on moving screenshots from one of the emulators/devices. Again, we blame ddmlib used in Spoon for that. 30 | 31 | **Solution:** Composer invokes `adb` binary to pull files from emulators/devices, we haven't seen problems with that in more than 700 builds on CI. 32 | 33 | **Problem 3:** Spoon pulled screenshots/files *after* finish of the whole test run on a device which slows down builds: `test_run_time + pull_files_time`. 34 | 35 | **Solution:** Composer pulls screenshots/files *reactively* after each test which basically leads to: `~test_run_time`. 36 | 37 | **Problem 4:** If test sharding is enabled (which we do all the time), Spoon HTML report is very hard to look at, especially if you want to find some particular test(s) and it's not failed. You have to either hover mouse over each test to find out its name or go into html/xml source and find on which emulator/device test was sharded in order to click on correct device and then find test by CMD+F on the page. 38 | 39 | **Solution:** HTML report we've built designed with usability and performance in mind. 40 | 41 | **Problem 5:** Html report can be very slow to load if you have lots of screenshots (which we do) since it displays all the screenshots of tests that were run on a particular device on a single page — it can take up to minutes to finish while you effectively unable to scroll page since scroll is jumping up and down each time new screenshot loaded. 42 | 43 | **Solution:** HTML report that we've built does not display screenshots on index and suite pages, screenshots are displayed only on the test page → fast page load. 44 | 45 | >With Composer we were able to make UI tests required part of CI for Pull Requests. 46 | >It's fast, reliable and uses RxJava which means that it's relatively easy to add more features combining complex async transformations. 47 | 48 | ### HTML Report 49 | 50 | Our Frontend Team [helped us](https://github.com/gojuno/composer/issues/11) build HTML Report for the Composer. 51 | 52 | >It's fast, small and designed in collaboration with our QAs and Developers who actually use it on daily basis to make it easy to use. 53 | 54 | Here are few screenshots: 55 | 56 | [Suite Page](demo/screenshot1.png) [Test Page](demo/screenshot2.png)[Test Page](demo/screenshot3.png) 57 | 58 | ## Usage 59 | 60 | Composer shipped as jar, to run it you need JVM 1.8+: `java -jar composer-latest-version.jar options`. 61 | 62 | #### Supported options 63 | 64 | ##### Required 65 | 66 | * `--apk` 67 | * Either relative or absolute path to application apk that needs to be tested. 68 | * Example: `--apk myapp.apk` 69 | * `--test-apk` 70 | * Either relative or absolute path to apk with tests. 71 | * Example: `--test-apk myapp-androidTest.apk` 72 | 73 | ##### Optional 74 | 75 | * `--help, -help, help, -h` 76 | * Print help and exit. 77 | * `--test-runner` 78 | * Fully qualified name of test runner class you're using. 79 | * Default: automatically parsed from `--test-apk`'s `AndroidManifest`. 80 | * Example: `--test-runner com.example.TestRunner` 81 | * `--shard` 82 | * Either `true` or `false` to enable/disable [test sharding][test sharding] which statically shards tests between available devices/emulators. 83 | * Default: `true`. 84 | * Example: `--shard false` 85 | * `--output-directory` 86 | * Either relative or absolute path to directory for output: reports, files from devices and so on. 87 | * Default: `composer-output` in current working directory. 88 | * Example: `--output-directory artifacts/composer-output` 89 | * `--instrumentation-arguments` 90 | * Key-value pairs to pass to Instrumentation Runner. 91 | * Default: empty. 92 | * Example: `--instrumentation-arguments myKey1 myValue1 myKey2 myValue2`. 93 | * `--verbose-output` 94 | * Either `true` or `false` to enable/disable verbose output for Composer. 95 | * Default: `false`. 96 | * Example: `--verbose-output true` 97 | * `--keep-output-on-exit` 98 | * Either `true` or `false` to keep/clean temporary output files used by Composer on exit. 99 | * Default: `false`. 100 | * Composer uses files to pipe output of external commands like `adb`, keeping them might be useful for debugging issues. 101 | * Example: `--keep-output-on-exit true` 102 | * `--devices` 103 | * Connected devices/emulators that will be used to run tests against. 104 | * Default: empty, tests will run on all connected devices/emulators. 105 | * Specifying both `--devices` and `--device-pattern` will result in an error. 106 | * Example: `--devices emulator-5554 emulator-5556` 107 | * `--device-pattern` 108 | * Connected devices/emulators that will be used to run tests against. 109 | * Default: empty, tests will run on all connected devices/emulators. 110 | * Specifying both `--device-pattern` and `--devices` will result in an error. 111 | * Example: `--device-pattern "emulator.+"` 112 | * `--install-timeout` 113 | * APK installation timeout in seconds. 114 | * Default: `120` seconds (2 minutes). 115 | * Applicable to both test APK and APK under test. 116 | * Example: `--install-timeout 20` 117 | * `--fail-if-no-tests` 118 | * Either `true` or `false` to enable/disable error on empty test suite. 119 | * Default: `true`. 120 | * `False` may be applicable when you run tests conditionally(via annotation/package filters) and empty suite is a valid outcome. 121 | * Example: `--fail-if-no-tests false` 122 | * `--with-orchestrator` 123 | * Either `true` or `false` to enable/disable running tests via AndroidX Test Orchestrator. 124 | * Default: `false`. 125 | * When enabled - minimizes shared state and isolates test crashes. 126 | * Requires test orchestrator & test services APKs to be installed on device before executing. 127 | * More info: https://developer.android.com/training/testing/junit-runner#using-android-test-orchestrator 128 | * Example: `--with-orchestrator true` 129 | * `--extra-apks` 130 | * Apks to be installed for utilities. What you would typically declare in gradle as `androidTestUtil` 131 | * Default: empty, only apk and test apk would be installed. 132 | * Works great with Orchestrator to install orchestrator & test services APKs. 133 | * Example: `--extra-apks path/to/apk/first.apk path/to/apk/second.apk` 134 | 135 | ##### Example 136 | 137 | Simplest : 138 | ```console 139 | java -jar composer-latest-version.jar \ 140 | --apk app/build/outputs/apk/example-debug.apk \ 141 | --test-apk app/build/outputs/apk/example-debug-androidTest.apk 142 | ``` 143 | 144 | With arguments : 145 | ```console 146 | java -jar composer-latest-version.jar \ 147 | --apk app/build/outputs/apk/example-debug.apk \ 148 | --test-apk app/build/outputs/apk/example-debug-androidTest.apk \ 149 | --test-runner com.example.test.ExampleTestRunner \ 150 | --output-directory artifacts/composer-output \ 151 | --instrumentation-arguments key1 value1 key2 value2 \ 152 | --verbose-output false \ 153 | --keep-output-on-exit false \ 154 | --with-orchestrator false 155 | ``` 156 | 157 | ### Download 158 | 159 | Composer is [available on jcenter](https://jcenter.bintray.com/com/gojuno/composer). 160 | 161 | >You can download it in your CI scripts or store it in your version control system (not recommended). 162 | 163 | ```console 164 | COMPOSER_VERSION=some-version 165 | curl --fail --location https://jcenter.bintray.com/com/gojuno/composer/composer/${COMPOSER_VERSION}/composer-${COMPOSER_VERSION}.jar --output /tmp/composer.jar 166 | ``` 167 | 168 | All the releases and changelogs can be found on [Releases Page](https://github.com/gojuno/composer/releases). 169 | 170 | ### 3rd-party Composer Gradle Plugin 171 | 172 | [@trevjonez](https://github.com/trevjonez) [built](https://github.com/gojuno/composer/issues/77) 🎉 [Gradle Plugin for Composer](https://github.com/trevjonez/composer-gradle-plugin) which allows you to configure and run Composer with Gradle. 173 | 174 | ### Swarmer 175 | 176 | Composer works great in combination with [Swarmer][swarmer] — another tool we've built at Juno. 177 | 178 | [Swarmer][swarmer] can create and start multiple emulators in parallel. In our [CI Pipeline][ci pipeline] we start emulators with Swarmer and then Composer runs tests on them. 179 | 180 | ### How to build 181 | 182 | #### All-in-one script (used in Travis build) 183 | 184 | Dependencies: `docker` and `bash`. 185 | 186 | ```console 187 | ci/build.sh 188 | ``` 189 | 190 | #### Build Composer 191 | 192 | Environment variable `ANDROID_HOME` must be set. 193 | 194 | ```console 195 | ./gradlew build 196 | ``` 197 | 198 | #### Build HTML report module 199 | 200 | Dependencies: `npm` and `nodejs`. 201 | 202 | ```console 203 | cd html-report 204 | npm install 205 | npm build 206 | ``` 207 | 208 | ## License 209 | 210 | ``` 211 | Copyright 2017 Juno, Inc. 212 | 213 | Licensed under the Apache License, Version 2.0 (the "License"); 214 | you may not use this file except in compliance with the License. 215 | You may obtain a copy of the License at 216 | 217 | http://www.apache.org/licenses/LICENSE-2.0 218 | 219 | Unless required by applicable law or agreed to in writing, software 220 | distributed under the License is distributed on an "AS IS" BASIS, 221 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 222 | See the License for the specific language governing permissions and 223 | limitations under the License. 224 | ``` 225 | 226 | [spoon]: https://github.com/square/spoon 227 | [test sharding]: https://developer.android.com/training/testing/junit-runner.html#sharding-tests 228 | [swarmer]: https://github.com/gojuno/swarmer 229 | [ci pipeline]: https://github.com/gojuno/engineering/tree/master/articles/ci_pipeline_and_custom_tools_of_android_projects 230 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/TestRun.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import com.gojuno.commander.android.* 4 | import com.gojuno.commander.os.Notification 5 | import com.gojuno.commander.os.nanosToHumanReadableTime 6 | import com.gojuno.commander.os.process 7 | import rx.Observable 8 | import rx.Single 9 | import rx.schedulers.Schedulers 10 | import java.io.File 11 | 12 | data class AdbDeviceTestRun( 13 | val adbDevice: AdbDevice, 14 | val tests: List, 15 | val passedCount: Int, 16 | val ignoredCount: Int, 17 | val failedCount: Int, 18 | val durationNanos: Long, 19 | val timestampMillis: Long, 20 | val logcat: File, 21 | val instrumentationOutput: File 22 | ) 23 | 24 | data class AdbDeviceTest( 25 | val adbDevice: AdbDevice, 26 | val className: String, 27 | val testName: String, 28 | val status: Status, 29 | val durationNanos: Long, 30 | val logcat: File, 31 | val files: List, 32 | val screenshots: List 33 | ) { 34 | sealed class Status { 35 | object Passed : Status() 36 | data class Ignored(val stacktrace: String) : Status() 37 | data class Failed(val stacktrace: String) : Status() 38 | } 39 | } 40 | 41 | fun AdbDevice.runTests( 42 | testPackageName: String, 43 | testRunnerClass: String, 44 | instrumentationArguments: String, 45 | outputDir: File, 46 | verboseOutput: Boolean, 47 | keepOutput: Boolean, 48 | useTestServices: Boolean 49 | ): Single { 50 | 51 | val adbDevice = this 52 | val logsDir = File(File(outputDir, "logs"), adbDevice.id) 53 | val instrumentationOutputFile = File(logsDir, "instrumentation.output") 54 | val commandPrefix = if (useTestServices) { 55 | "CLASSPATH=$(pm path androidx.test.services) app_process / androidx.test.services.shellexecutor.ShellMain " 56 | } else "" 57 | 58 | val runTests = process( 59 | commandAndArgs = listOf( 60 | adb, 61 | "-s", adbDevice.id, 62 | "shell", "${commandPrefix}am instrument -w -r $instrumentationArguments $testPackageName/$testRunnerClass" 63 | ), 64 | timeout = null, 65 | redirectOutputTo = instrumentationOutputFile, 66 | keepOutputOnExit = keepOutput 67 | ).share() 68 | 69 | @Suppress("destructure") 70 | val runningTests = runTests 71 | .ofType(Notification.Start::class.java) 72 | .flatMap { readInstrumentationOutput(it.output) } 73 | .asTests() 74 | .doOnNext { test -> 75 | val status = when (test.status) { 76 | is InstrumentationTest.Status.Passed -> "passed" 77 | is InstrumentationTest.Status.Ignored -> "ignored" 78 | is InstrumentationTest.Status.Failed -> "failed" 79 | } 80 | 81 | adbDevice.log( 82 | "Test ${test.index}/${test.total} $status in " + 83 | "${test.durationNanos.nanosToHumanReadableTime()}: " + 84 | "${test.className}.${test.testName}" 85 | ) 86 | } 87 | .flatMap { test -> 88 | pullTestFiles(adbDevice, test, outputDir, verboseOutput) 89 | .toObservable() 90 | .subscribeOn(Schedulers.io()) 91 | .map { pulledFiles -> test to pulledFiles } 92 | } 93 | .toList() 94 | 95 | val adbDeviceTestRun = Observable 96 | .zip( 97 | Observable.fromCallable { System.nanoTime() }, 98 | runningTests, 99 | { time, tests -> time to tests } 100 | ) 101 | .map { (startTimeNanos, testsWithPulledFiles) -> 102 | val tests = testsWithPulledFiles.map { it.first } 103 | 104 | AdbDeviceTestRun( 105 | adbDevice = adbDevice, 106 | tests = testsWithPulledFiles.map { (test, pulledFiles) -> 107 | AdbDeviceTest( 108 | adbDevice = adbDevice, 109 | className = test.className, 110 | testName = test.testName, 111 | status = when (test.status) { 112 | is InstrumentationTest.Status.Passed -> AdbDeviceTest.Status.Passed 113 | is InstrumentationTest.Status.Ignored -> AdbDeviceTest.Status.Ignored(test.status.stacktrace) 114 | is InstrumentationTest.Status.Failed -> AdbDeviceTest.Status.Failed(test.status.stacktrace) 115 | }, 116 | durationNanos = test.durationNanos, 117 | logcat = logcatFileForTest(logsDir, test.className, test.testName), 118 | files = pulledFiles.files.sortedBy { it.name }, 119 | screenshots = pulledFiles.screenshots.sortedBy { it.name } 120 | ) 121 | }, 122 | passedCount = tests.count { it.status is InstrumentationTest.Status.Passed }, 123 | ignoredCount = tests.count { it.status is InstrumentationTest.Status.Ignored }, 124 | failedCount = tests.count { it.status is InstrumentationTest.Status.Failed }, 125 | durationNanos = System.nanoTime() - startTimeNanos, 126 | timestampMillis = System.currentTimeMillis(), 127 | logcat = logcatFileForDevice(logsDir), 128 | instrumentationOutput = instrumentationOutputFile 129 | ) 130 | } 131 | 132 | val testRunFinish = runTests.ofType(Notification.Exit::class.java).cache() 133 | 134 | val saveLogcat = saveLogcat(adbDevice, logsDir) 135 | .map { Unit } 136 | // TODO: Stop when all expected tests were parsed from logcat and not when instrumentation finishes. 137 | // Logcat may be delivered with delay and that may result in missing logcat for last (n) tests (it's just a theory though). 138 | .takeUntil(testRunFinish) 139 | .startWith(Unit) // To allow zip finish normally even if no tests were run. 140 | 141 | return Observable 142 | .zip(adbDeviceTestRun, saveLogcat, testRunFinish) { suite, _, _ -> suite } 143 | .doOnSubscribe { adbDevice.log("Starting tests...") } 144 | .doOnNext { testRun -> 145 | adbDevice.log( 146 | "Test run finished, " + 147 | "${testRun.passedCount} passed, " + 148 | "${testRun.failedCount} failed, took " + 149 | "${testRun.durationNanos.nanosToHumanReadableTime()}." 150 | ) 151 | } 152 | .doOnError { adbDevice.log("Error during tests run: $it") } 153 | .toSingle() 154 | } 155 | 156 | data class PulledFiles( 157 | val files: List, 158 | val screenshots: List 159 | ) 160 | 161 | private fun pullTestFiles(adbDevice: AdbDevice, test: InstrumentationTest, outputDir: File, verboseOutput: Boolean): Single = Single 162 | // TODO: Add support for spoon files dir. 163 | .fromCallable { 164 | File(File(File(outputDir, "screenshots"), adbDevice.id), test.className).apply { mkdirs() } 165 | } 166 | .flatMap { screenshotsFolderOnHostMachine -> 167 | adbDevice 168 | .pullFolder( 169 | // TODO: Add support for internal storage and external storage strategies. 170 | folderOnDevice = "/storage/emulated/0/app_spoon-screenshots/${test.className}/${test.testName}", 171 | folderOnHostMachine = screenshotsFolderOnHostMachine, 172 | logErrors = verboseOutput 173 | ) 174 | .map { File(screenshotsFolderOnHostMachine, test.testName) } 175 | } 176 | .map { screenshotsFolderOnHostMachine -> 177 | PulledFiles( 178 | files = emptyList(), // TODO: Pull test files. 179 | screenshots = screenshotsFolderOnHostMachine.let { 180 | when (it.exists()) { 181 | true -> it.listFiles().toList() 182 | else -> emptyList() 183 | } 184 | } 185 | ) 186 | } 187 | 188 | internal fun String.parseTestClassAndName(): Pair? { 189 | val index = indexOf("TestRunner") 190 | if (index < 0) return null 191 | 192 | val tokens = substring(index, length).split(':') 193 | if (tokens.size != 3) return null 194 | 195 | val startedOrFinished = tokens[1].trimStart() 196 | if (startedOrFinished == "started" || startedOrFinished == "finished") { 197 | return tokens[2].substringAfter("(").removeSuffix(")") to tokens[2].substringBefore("(").trim() 198 | } 199 | return null 200 | } 201 | 202 | private fun saveLogcat(adbDevice: AdbDevice, logsDir: File): Observable> = Observable 203 | .just(logsDir to logcatFileForDevice(logsDir)) 204 | .flatMap { (logsDir, fullLogcatFile) -> adbDevice.redirectLogcatToFile(fullLogcatFile).toObservable().map { logsDir to fullLogcatFile } } 205 | .flatMap { (logsDir, fullLogcatFile) -> 206 | data class result(val logcat: String = "", val startedTestClassAndName: Pair? = null, val finishedTestClassAndName: Pair? = null) 207 | 208 | tail(fullLogcatFile) 209 | .scan(result()) { previous, newline -> 210 | val logcat = when (previous.startedTestClassAndName != null && previous.finishedTestClassAndName != null) { 211 | true -> newline 212 | false -> "${previous.logcat}\n$newline" 213 | } 214 | 215 | // Implicitly expecting to see logs from `android.support.test.internal.runner.listener.LogRunListener`. 216 | // Was not able to find more reliable solution to capture logcat per test. 217 | val startedTest: Pair? = newline.parseTestClassAndName() 218 | val finishedTest: Pair? = newline.parseTestClassAndName() 219 | 220 | result( 221 | logcat = logcat, 222 | startedTestClassAndName = startedTest ?: previous.startedTestClassAndName, 223 | finishedTestClassAndName = finishedTest // Actual finished test should always overwrite previous. 224 | ) 225 | } 226 | .filter { it.startedTestClassAndName != null && it.startedTestClassAndName == it.finishedTestClassAndName } 227 | .map { result -> 228 | logcatFileForTest(logsDir, className = result.startedTestClassAndName!!.first, testName = result.startedTestClassAndName.second) 229 | .apply { parentFile.mkdirs() } 230 | .writeText(result.logcat) 231 | 232 | result.startedTestClassAndName 233 | } 234 | } 235 | 236 | private fun logcatFileForDevice(logsDir: File) = File(logsDir, "full.logcat") 237 | 238 | private fun logcatFileForTest(logsDir: File, className: String, testName: String): File = File(File(logsDir, className), "$testName.logcat") 239 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Juno Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /composer/src/main/kotlin/com/gojuno/composer/Main.kt: -------------------------------------------------------------------------------- 1 | package com.gojuno.composer 2 | 3 | import com.gojuno.commander.android.connectedAdbDevices 4 | import com.gojuno.commander.android.installApk 5 | import com.gojuno.commander.os.log 6 | import com.gojuno.commander.os.nanosToHumanReadableTime 7 | import com.gojuno.composer.html.writeHtmlReport 8 | import com.google.gson.Gson 9 | import rx.Observable 10 | import rx.schedulers.Schedulers 11 | import java.io.File 12 | import java.util.* 13 | import java.util.concurrent.TimeUnit 14 | 15 | sealed class Exit(val code: Int, val message: String?) { 16 | object Ok : Exit(code = 0, message = null) 17 | object NoDevicesAvailableForTests : Exit(code = 1, message = "Error: No devices available for tests.") 18 | class TestRunnerNotFound(message: String) : Exit(code = 1, message = message) 19 | class TestPackageNotFound(message: String) : Exit(code = 1, message = message) 20 | object ThereWereFailedTests : Exit(code = 1, message = "Error: There were failed tests.") 21 | object NoTests : Exit(code = 1, message = "Error: 0 tests were run.") 22 | } 23 | 24 | fun exit(exit: Exit) { 25 | if (exit.message != null) { 26 | log(exit.message) 27 | } 28 | System.exit(exit.code) 29 | } 30 | 31 | fun main(rawArgs: Array) { 32 | val startTime = System.nanoTime() 33 | 34 | val args = parseArgs(rawArgs) 35 | 36 | if (args.verboseOutput) { 37 | log("$args") 38 | } 39 | 40 | val testPackage: TestPackage.Valid = parseTestPackage(args.testApkPath).let { 41 | when (it) { 42 | is TestPackage.Valid -> it 43 | is TestPackage.ParseError -> { 44 | exit(Exit.TestPackageNotFound(message = it.error)) 45 | return 46 | } 47 | } 48 | } 49 | 50 | val testRunner: TestRunner.Valid = 51 | if (!args.testRunner.isEmpty()) { 52 | TestRunner.Valid(args.testRunner) 53 | } else { 54 | parseTestRunner(args.testApkPath).let { 55 | when (it) { 56 | is TestRunner.Valid -> it 57 | is TestRunner.ParseError -> { 58 | exit(Exit.TestRunnerNotFound(message = it.error)) 59 | return 60 | } 61 | } 62 | } 63 | } 64 | 65 | val suites = runAllTests(args, testPackage, testRunner) 66 | 67 | val duration = (System.nanoTime() - startTime) 68 | 69 | val totalPassed = suites.sumBy { it.passedCount } 70 | val totalFailed = suites.sumBy { it.failedCount } 71 | val totalIgnored = suites.sumBy { it.ignoredCount } 72 | 73 | log("Test run finished, total passed = $totalPassed, total failed = $totalFailed, total ignored = $totalIgnored, took ${duration.nanosToHumanReadableTime()}.") 74 | 75 | when { 76 | totalPassed > 0 && totalFailed == 0 -> exit(Exit.Ok) 77 | totalPassed == 0 && totalFailed == 0 -> if(args.failIfNoTests) exit(Exit.NoTests) else exit(Exit.Ok) 78 | else -> exit(Exit.ThereWereFailedTests) 79 | } 80 | 81 | log("Test run finished took ${duration.nanosToHumanReadableTime()}.") 82 | exit(Exit.Ok) 83 | } 84 | 85 | private fun runAllTests(args: Args, testPackage: TestPackage.Valid, testRunner: TestRunner.Valid): List { 86 | val gson = Gson() 87 | 88 | return connectedAdbDevices() 89 | .map { devices -> 90 | when (args.devicePattern.isEmpty()) { 91 | true -> devices 92 | false -> Regex(args.devicePattern).let { regex -> devices.filter { regex.matches(it.id) } } 93 | } 94 | } 95 | .map { 96 | when (args.devices.isEmpty()) { 97 | true -> it 98 | false -> it.filter { args.devices.contains(it.id) } 99 | } 100 | } 101 | .map { 102 | it.filter { it.online }.apply { 103 | if (isEmpty()) { 104 | exit(Exit.NoDevicesAvailableForTests) 105 | } 106 | } 107 | } 108 | .doOnNext { log("${it.size} connected adb device(s): $it") } 109 | .flatMap { connectedAdbDevices -> 110 | val runTestsOnDevices: List> = connectedAdbDevices.mapIndexed { index, device -> 111 | val installTimeout = Pair(args.installTimeoutSeconds, TimeUnit.SECONDS) 112 | val installAppApk = device.installApk(pathToApk = args.appApkPath, timeout = installTimeout) 113 | val installTestApk = device.installApk(pathToApk = args.testApkPath, timeout = installTimeout) 114 | val installApks = mutableListOf(installAppApk, installTestApk) 115 | installApks.addAll(args.extraApks.map { 116 | device.installApk(pathToApk = it, timeout = installTimeout) 117 | }) 118 | 119 | Observable 120 | .concat(installApks) 121 | // Work with each device in parallel, but install apks sequentially on a device. 122 | .subscribeOn(Schedulers.io()) 123 | .toList() 124 | .flatMap { 125 | val targetInstrumentation: List> 126 | val testPackageName: String 127 | val testRunnerClass: String 128 | 129 | if (args.runWithOrchestrator) { 130 | targetInstrumentation = listOf("targetInstrumentation" to "${testPackage.value}/${testRunner.value}") 131 | testPackageName = "androidx.test.orchestrator" 132 | testRunnerClass = "androidx.test.orchestrator.AndroidTestOrchestrator" 133 | } else { 134 | targetInstrumentation = emptyList() 135 | testPackageName = testPackage.value 136 | testRunnerClass = testRunner.value 137 | } 138 | 139 | val instrumentationArguments = 140 | buildShardArguments( 141 | shardingOn = args.shard, 142 | shardIndex = index, 143 | devices = connectedAdbDevices.size 144 | ) + args.instrumentationArguments.pairArguments() + targetInstrumentation 145 | 146 | device 147 | .runTests( 148 | testPackageName = testPackageName, 149 | testRunnerClass = testRunnerClass, 150 | instrumentationArguments = instrumentationArguments.formatInstrumentationArguments(), 151 | outputDir = File(args.outputDirectory), 152 | verboseOutput = args.verboseOutput, 153 | keepOutput = args.keepOutputOnExit, 154 | useTestServices = args.runWithOrchestrator 155 | ) 156 | .flatMap { adbDeviceTestRun -> 157 | writeJunit4Report( 158 | suite = adbDeviceTestRun.toSuite(testPackage.value), 159 | outputFile = File(File(args.outputDirectory, "junit4-reports"), "${device.id}.xml") 160 | ).toSingleDefault(adbDeviceTestRun) 161 | } 162 | .subscribeOn(Schedulers.io()) 163 | .toObservable() 164 | } 165 | } 166 | Observable.zip(runTestsOnDevices, { results -> results.map { it as AdbDeviceTestRun } }) 167 | } 168 | .map { adbDeviceTestRuns -> 169 | when (args.shard) { 170 | true -> {// In "shard=true" mode test runs from all devices arecombined into one suite of tests. 171 | listOf(Suite( 172 | testPackage = testPackage.value, 173 | devices = adbDeviceTestRuns.fold(emptyList()) { devices, adbDeviceTestRun -> 174 | devices + Device( 175 | id = adbDeviceTestRun.adbDevice.id, 176 | model = adbDeviceTestRun.adbDevice.model,logcat = adbDeviceTestRun.logcat, 177 | instrumentationOutput = adbDeviceTestRun.instrumentationOutput 178 | ) 179 | }, 180 | tests = adbDeviceTestRuns.map { it.tests }.fold(emptyList()) { result, tests -> 181 | result + tests 182 | }, 183 | passedCount = adbDeviceTestRuns.sumBy { it.passedCount }, 184 | ignoredCount = adbDeviceTestRuns.sumBy { it.ignoredCount }, 185 | failedCount = adbDeviceTestRuns.sumBy { it.failedCount }, 186 | durationNanos = adbDeviceTestRuns.map { it.durationNanos }.max() ?: -1, 187 | timestampMillis = adbDeviceTestRuns.map { it.timestampMillis }.min() ?: -1 188 | ))} 189 | 190 | false -> { 191 | // In "shard=false" mode test run from each device is represented as own suite of tests. 192 | adbDeviceTestRuns.map { 193 | it.toSuite(testPackage.value) 194 | } 195 | } 196 | } 197 | } 198 | .flatMap { suites -> 199 | log("Generating HTML report...") 200 | val htmlReportStartTime = System.nanoTime() 201 | writeHtmlReport(gson, suites, File(args.outputDirectory, "html-report"), Date()) 202 | .doOnCompleted { log("HTML report generated, took ${(System.nanoTime() - htmlReportStartTime).nanosToHumanReadableTime()}.") } 203 | .andThen(Observable.just(suites)) 204 | } 205 | .toBlocking() 206 | .first() 207 | } 208 | 209 | private fun List.pairArguments(): List> = 210 | foldIndexed(mutableListOf()) { index, accumulator, value -> 211 | accumulator.apply { 212 | if (index % 2 == 0) { 213 | add(value to "") 214 | } else { 215 | set(lastIndex, last().first to value) 216 | } 217 | } 218 | } 219 | 220 | private fun buildSingleTestArguments(testMethod : String) : List> = 221 | listOf("class" to testMethod) 222 | 223 | private fun buildShardArguments(shardingOn: Boolean, shardIndex: Int, devices: Int): List> = when { 224 | shardingOn && devices > 1 -> listOf( 225 | "numShards" to "$devices", 226 | "shardIndex" to "$shardIndex" 227 | ) 228 | 229 | else -> emptyList() 230 | } 231 | 232 | private fun List>.formatInstrumentationArguments(): String = when (isEmpty()) { 233 | true -> "" 234 | false -> " " + joinToString(separator = " ") { "-e ${it.first} ${it.second}" } 235 | } 236 | 237 | data class Suite( 238 | val testPackage: String, 239 | val devices: List, 240 | val tests: List, // TODO: switch to separate Test class. 241 | val passedCount: Int, 242 | val ignoredCount: Int, 243 | val failedCount: Int, 244 | val durationNanos: Long, 245 | val timestampMillis: Long 246 | ) 247 | 248 | data class Device( 249 | val id: String, 250 | val model:String, 251 | val logcat: File, 252 | val instrumentationOutput: File 253 | ) 254 | 255 | fun AdbDeviceTestRun.toSuite(testPackage: String): Suite = Suite( 256 | testPackage = testPackage, 257 | devices = listOf(Device( 258 | id = adbDevice.id, 259 | model = adbDevice.model, 260 | logcat = logcat, 261 | instrumentationOutput = instrumentationOutput 262 | )), 263 | tests = tests, 264 | passedCount = passedCount, 265 | ignoredCount = ignoredCount, 266 | failedCount = failedCount, 267 | durationNanos = durationNanos, 268 | timestampMillis = timestampMillis 269 | ) 270 | --------------------------------------------------------------------------------