Repository: runningcode/fladle
Pull request #88 (2020-04-13 20:52:36)

Update current flank flags
New flags added
* runTimeout
* ignoreFailedTests
* disableSharding
* smartFlankDisableUpload
* testRunnerClass
* localResultsDir
* numUniformShards
* clientDetails
* testTargetsAlwaysRun
* otherFiles
* networkProfile

pawelpasterz: Hey @runningcode , please take a look and let me know what do you think. Thanks!
pawelpasterz: Hey @runningcode thanks for review and comments. I implemented changes, please verify. Thanks!
pawelpasterz: > Nice work! I'm going to wait until an updated version of Flank is released with these features before merging, is that okay? Otherwise people might look at the readme and try to use features that don't work with any released version.

Definitely, I will keep this PR updated if any new flag would appear in flank
runningcode: Hey! I was looking in to merging this, but when testing with Flank 8.1.0 i ran in to this issue which caused flank to hang:
```
com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [simple type, class ftl.reports.xml.model.JUnitTestSuite] value failed for JSON property hostname due to missing (therefore NULL) value for creator parameter hostname which is a non-nullable type
 at [Source: (byte[])"<?xml version='1.0' encoding='UTF-8' ?>
<testsuites>
  <testsuite name="Nexus5-23-en-portrait" tests="1" failures="0" flakes="0" errors="0" skipped="0" time="2.594" timestamp="2020-04-23T20:40:34">
    <testcase name="seeView" classname="com.osacky.flank.gradle.sample.ExampleInstrumentedTest" time="2.594"/>
  </testsuite>
  <testsuite name="Pixel2-26-en-portrait" tests="1" failures="0" flakes="0" errors="0" skipped="0" time="2.712" timestamp="2020-04-23T20:40:14">
    <testcase name="seeView" cl"[truncated 109 bytes]; line: 5, column: 3] (through reference chain: ftl.reports.xml.model.JUnitTestResult["testsuite"]->java.util.ArrayList[0]->ftl.reports.xml.model.JUnitTestSuite["hostname"])
        at com.fasterxml.jackson.module.kotlin.KotlinValueInstantiator.createFromObjectWith(KotlinValueInstantiator.kt:107)
        at com.fasterxml.jackson.databind.deser.impl.PropertyBasedCreator.build(PropertyBasedCreator.java:198)
        at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:488)
        at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1287)
        at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:326)
        at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:159)
        at com.fasterxml.jackson.dataformat.xml.deser.WrapperHandlingDeserializer.deserialize(WrapperHandlingDeserializer.java:114)
        at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:286)
        at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:245)
        at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:27)
        at com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:530)
        at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeWithErrorWrapping(BeanDeserializer.java:528)
        at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:417)
        at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1287)
        at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:326)
        at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:159)
        at com.fasterxml.jackson.dataformat.xml.deser.WrapperHandlingDeserializer.deserialize(WrapperHandlingDeserializer.java:114)
        at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4202)
        at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3266)
        at ftl.reports.xml.JUnitXmlKt.parseAllSuitesXml(JUnitXml.kt:50)
        at ftl.reports.xml.JUnitXmlKt.parseAllSuitesXml(JUnitXml.kt:54)
        at ftl.gc.GcStorage.downloadJunitXml(GcStorage.kt:94)
        at ftl.args.ArgsHelper.calculateShards(ArgsHelper.kt:222)
        at ftl.args.AndroidTestShard.getTestShardChunks(AndroidTestShard.kt:26)
        at ftl.run.AndroidTestRunner$runTests$2.invokeSuspend(AndroidTestRunner.kt:38)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.ResumeModeKt.resumeUninterceptedMode(ResumeMode.kt:45)
        at kotlinx.coroutines.internal.ScopeCoroutine.afterCompletionInternal(Scopes.kt:32)
        at kotlinx.coroutines.JobSupport.completeStateFinalization(JobSupport.kt:310)
        at kotlinx.coroutines.JobSupport.tryFinalizeFinishingState(JobSupport.kt:236)
        at kotlinx.coroutines.JobSupport.tryMakeCompletingSlowPath(JobSupport.kt:849)
        at kotlinx.coroutines.JobSupport.tryMakeCompleting(JobSupport.kt:811)
        at kotlinx.coroutines.JobSupport.makeCompletingOnce$kotlinx_coroutines_core(JobSupport.kt:787)
        at kotlinx.coroutines.AbstractCoroutine.resumeWith(AbstractCoroutine.kt:111)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:46)
        at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:241)
        at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:270)
        at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:79)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:54)
        at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:36)
        at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
        at ftl.cli.firebase.test.android.AndroidRunCommand.run(AndroidRunCommand.kt:49)
        at picocli.CommandLine.executeUserObject(CommandLine.java:1729)
        at picocli.CommandLine.access$900(CommandLine.java:145)
        at picocli.CommandLine$RunLast.handle(CommandLine.java:2101)
        at picocli.CommandLine$RunLast.handle(CommandLine.java:2068)
        at picocli.CommandLine$AbstractParseResultHandler.execute(CommandLine.java:1935)
        at picocli.CommandLine.execute(CommandLine.java:1864)
        at ftl.Main$Companion.main(Main.kt:41)
        at ftl.Main.main(Main.kt)
```

Are we adding a flag that doesn't work with the old version of flank?
runningcode: Looking good. I'll test this tomorrow now that I figured out the issue that was causing the crash for me!

Also, I won't block this PR since it already is quite large, but I just wanted to also give you a heads up: From now on, I would like new extensions in fladle to use [Gradle's lazy property API](https://docs.gradle.org/current/userguide/lazy_configuration.html). This will allow for lazy evaluation and faster configuration times.

I started doing some migration here: https://github.com/runningcode/fladle/commit/f92bd12927db99e6cfd10aa32883df465183ef74#diff-670e340c5b646cd261359b8466643022R40

Anyways, don't worry about it. I'll do the migration for you once I merge this PR.

Let's figure out that robo directives api first!

Patch:
diff --git a/README.md b/README.md
index 318f340..80a83a2 100644
--- a/README.md
+++ b/README.md
@@ -85,7 +85,7 @@ fladle {
     instrumentationApk = "$buildDir/outputs/apk/androidTest/debug/sample-debug-androidTest.apk"
     additionalTestApks = ["$buildDir/outputs/apk/debug/sample-debug.apk": ["$buildDir/outputs/apk/androidTest/debug/sample2-debug-androidTest.apk"]]
     autoGoogleLogin = true
-    testShards = 5
+    testShards = 5 //or numUniformShards=5 cannot use both
     shardTime = 120
     smartFlankGcsPath = "gs://tmp_flank/flank/test_app_android.xml"
     configs {
@@ -98,6 +98,30 @@ fladle {
     }
     resultsBucket("my-results-bucket-name")
     keepFilePath = true
+    runTimout = 45m
+    ignoreFailedTests = false
+    disableSharding = false
+    smartFlankDisableUpload = false
+    testRunnerClass = "com.foo.TestRunner"
+    localResultsDir = "flank"
+    clientDetails = [
+      "key1": "value1",
+      "key2": "value2"
+    ]
+    testTargetsAlwaysRun = [
+      "com.example.TestSuite#test1",
+      "com.example.TestSuite#test2"
+    ]
+    otherFiles = [
+      "/sdcard/dir1/file1.txt": "/my/example/path/file1.txt",
+      "/sdcard/dir2/file2.txt": "/my/example/path/file2.txt"
+    ]
+    networkProfile = "LTE"
+    roboDirectives = [
+      ["click", "button1", ""],
+      ["ignore", "button2"],
+      ["text", "field1", "my text"],
+    ]
 }
 ```
 
@@ -176,7 +200,7 @@ A list of paths that will be copied from the device's storage to the designated
 List of regex that is matched against bucket paths (for example: `2019-01-09_00:13:06.106000_YCKl/shard_0/NexusLowRes-28-en-portrait/bugreport.txt`) for files to be downloaded after a flank run.
 
 ### timeoutMin
-The max time in minutes this test execution can run before it is cancelled (default: 15 min). It does not include any time necessary to prepare and clean up the target device. The maximum possible testing time is 30m on physical devices and 60m on virtual devices. 
+The max time in minutes this test execution can run before it is cancelled (default: 15 min). It does not include any time necessary to prepare and clean up the target device. The maximum possible testing time is 30m on physical devices and 60m on virtual devices.
 
 ### recordVideo
 Enable video recording during the test. Enabled by default.
@@ -193,11 +217,60 @@ Keeps the full path of downloaded files from a Google Cloud Storage bucket. Requ
 ### resultsDir
 The name of a unique Google Cloud Storage object within the results bucket where raw test results will be stored. The default is a timestamp with a random suffix.
 
+### disableSharding
+Disables sharding. All tests will run on the same device. Useful for parameterized tests which do not support sharding. (default: false)
+
+### smartFlankDisableUpload
+Disables smart flank JUnit XML uploading. Useful for preventing timing data from being updated. (default: false)
+[What is Smart Flank?](https://github.com/Flank/flank/blob/master/docs/smart_flank.md)
+
+### testRunnerClass
+The fully-qualified Java class name of the instrumentation test runner (default: the test manifest is parsed to determine the class name).
+
+### localResultsDir
+The local directory to store the test results. Folder is DELETED before each run to ensure only artifacts from the new run are saved.
+
+### testTargetsAlwaysRun
+Always run - these tests are inserted at the beginning of every shard. Useful if you need to grant permissions or login before other tests run
+
+####  *** Available for flank version higher than 8.1.0 ***
+
+### runTimeout
+The max time this test run can execute before it is cancelled. s (seconds), m (minutes), h (hours) suffixes are acceptable, mixes like 1h45m are currently not supported (default: unlimited).
+Examples:
+* 20, 20s -> 20 seconds
+* 30m -> 30 minutes
+* 2h -> 2 hours
+
+### ignoreFailedTests
+Always return successful task completion even when there are failed tests. Useful when parsing JUnit XML to determine failure. (default: false)
+
+### numUniformShards
+Specifies the number of shards into which you want to evenly distribute test cases. The shards are run in parallel on separate devices. For example, if your test execution contains 20 test cases and you specify four shards, each shard executes five test cases. The number of shards should be less than the total number of test cases. The number of shards specified must be >= 1 and <= 50. This option cannot be used along max-test-shards and is not compatible with smart sharding ([Smart Flank](https://github.com/Flank/flank/blob/master/docs/smart_flank.md)). If you want to take benefits of smart sharding use max-test-shards instead. (default: null)
+
+### clientDetails
+A key-value map of additional details to attach to the test matrix.([clientDetails in Google Cloud Docs](https://cloud.google.com/sdk/gcloud/reference/beta/firebase/test/android/run#--client-details)) Arbitrary key-value pairs may be attached to a test matrix to provide additional context about the tests being run. When consuming the test results, such as in Cloud Functions or a CI system, these details can add additional context such as a link to the corresponding pull request. ([Access Client Details](https://firebase.google.com/docs/test-lab/extend-with-functions#access_client_details))
+
+### otherFiles
+A list of device-path: file-path pairs that indicate the device paths to push files to the device before starting tests, and the paths of files to push. Device paths must be under absolute, whitelisted paths (${EXTERNAL_STORAGE}, or ${ANDROID_DATA}/local/tmp). Source file paths may be in the local filesystem or in Google Cloud Storage (gs://…).
+
+### networkProfile
+The name of the network traffic profile, for example LTE, HSPA, etc, which consists of a set of parameters to emulate network conditions when running the test (default: no network shaping; see available profiles listed by the `flank test network-profiles list` command). This feature only works on physical devices.
+
+### roboScript
+The path to a Robo Script JSON file. The path may be in the local filesystem or in Google Cloud Storage using gs:// notation. You can guide the Robo test to perform specific actions by recording a Robo Script in Android Studio and then specifying this argument. Learn more at [DOCS](https://firebase.google.com/docs/test-lab/robo-ux-test#scripting).
+
+### roboDirectives
+List of robo_directives that you can use to customize the behavior of Robo test. The type specifies the action type of the directive, which may take on values click, text or ignore. Each directive is list of String = [type, key, value]. Each key should be the Android resource name of a target UI element and each value should be the text input for that element. Values are only permitted for text type elements, so no value should be specified for click and ignore type elements.
+
 ---
 ## Results
 By default, results are placed in the `build/fladle/results/<matrix name>` directly.
+
 A merged junit report is available in `JUnitReport.xml`
+
 Only on failure, an html report is generated in that directory.
+
 ---
 ## Error APK file not found
 The app APK and the instrumentation apk are expected to have already been generated before calling runFlank.
diff --git a/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfig.kt b/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfig.kt
index fe10ec9..6d81714 100644
--- a/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfig.kt
+++ b/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfig.kt
@@ -55,4 +55,96 @@ interface FladleConfig {
   var resultsDir: String?
 
   var additionalTestApks: Map<String, List<String>>
+
+  /**
+   * The max time this test run can execute before it is cancelled (default: unlimited).
+   */
+  var runTimeout: String?
+
+  /**
+   * Terminate with exit code 0 when there are failed tests.
+   * Useful for Fladle and other gradle plugins that don't expect the process to have a non-zero exit code.
+   * The JUnit XML is used to determine failure. (default: false)
+   */
+  var ignoreFailedTests: Boolean
+
+  /**
+   * Disables sharding. Useful for parameterized tests. (default: false)
+   */
+  var disableSharding: Boolean
+
+  /**
+   * Disables smart flank JUnit XML uploading. Useful for preventing timing data from being updated. (default: false)
+   */
+  var smartFlankDisableUpload: Boolean
+
+  /**
+   * The fully-qualified Java class name of the instrumentation test runner
+   * (default: the last name extracted from the APK manifest).
+   */
+  var testRunnerClass: String?
+
+  /**
+   * Local folder to store the test result.
+   * Folder is DELETED before each run to ensure only artifacts from the new run are saved.
+   */
+  var localResultsDir: String?
+
+  /**
+   * Specifies the number of shards into which you want to evenly distribute test cases.
+   * The shards are run in parallel on separate devices. For example,
+   * if your test execution contains 20 test cases and you specify four shards, each shard executes five test cases.
+   * The number of shards should be less than the total number of test cases.
+   * The number of shards specified must be >= 1 and <= 50.
+   * This option cannot be used along max-test-shards and is not compatible with smart sharding.
+   * If you want to take benefits of smart sharding use max-test-shards instead.
+   * (default: null)
+   */
+  var numUniformShards: Int?
+
+  /**
+   * A key-value map of additional details to attach to the test matrix.
+   * Arbitrary key-value pairs may be attached to a test matrix to provide additional context about the tests being run.
+   * When consuming the test results, such as in Cloud Functions or a CI system,
+   * these details can add additional context such as a link to the corresponding pull request.
+   */
+  var clientDetails: Map<String, String>
+
+  /**
+   * Always run - these tests are inserted at the beginning of every shard
+   * useful if you need to grant permissions or login before other tests run
+   */
+  var testTargetsAlwaysRun: List<String>
+
+  /**
+   * A list of device-path: file-path pairs that indicate the device paths to push files to the device before starting tests, and the paths of files to push.
+   * Device paths must be under absolute, whitelisted paths (${EXTERNAL_STORAGE}, or ${ANDROID_DATA}/local/tmp).
+   * Source file paths may be in the local filesystem or in Google Cloud Storage (gs://…).
+   */
+  var otherFiles: Map<String, String>
+
+  /**
+   * The name of the network traffic profile, for example LTE, HSPA, etc,
+   * which consists of a set of parameters to emulate network conditions when running the test
+   * (default: no network shaping; see available profiles listed by the `flank test network-profiles list` command).
+   * This feature only works on physical devices.
+   */
+  var networkProfile: String?
+
+  /**
+   * The path to a Robo Script JSON file.
+   * The path may be in the local filesystem or in Google Cloud Storage using gs:// notation.
+   * You can guide the Robo test to perform specific actions by recording a Robo Script in Android Studio and then specifying this argument.
+   * Learn more at [https://firebase.google.com/docs/test-lab/robo-ux-test#scripting].
+   */
+  var roboScript: String?
+
+  /**
+   * List of robo_directives that you can use to customize the behavior of Robo test.
+   * The type specifies the action type of the directive, which may take on values click, text or ignore.
+   * Each directive is list of String = [type, key, value]
+   * Each key should be the Android resource name of a target UI element and each value should be the text input for that element.
+   * Values are only permitted for text type elements, so no value should be specified for click and ignore type elements.
+   */
+  var roboDirectives: List<List<String>>
 }
diff --git a/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfigImpl.kt b/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfigImpl.kt
index 38ea583..7d2a80c 100644
--- a/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfigImpl.kt
+++ b/buildSrc/src/main/java/com/osacky/flank/gradle/FladleConfigImpl.kt
@@ -25,5 +25,18 @@ data class FladleConfigImpl(
   override var resultsBucket: String?,
   override var keepFilePath: Boolean,
   override var resultsDir: String?,
-  override var additionalTestApks: Map<String, List<String>>
+  override var additionalTestApks: Map<String, List<String>>,
+  override var runTimeout: String?,
+  override var ignoreFailedTests: Boolean,
+  override var disableSharding: Boolean,
+  override var smartFlankDisableUpload: Boolean,
+  override var testRunnerClass: String?,
+  override var localResultsDir: String?,
+  override var numUniformShards: Int?,
+  override var clientDetails: Map<String, String>,
+  override var testTargetsAlwaysRun: List<String>,
+  override var otherFiles: Map<String, String>,
+  override var networkProfile: String?,
+  override var roboScript: String?,
+  override var roboDirectives: List<List<String>>
 ) : FladleConfig
diff --git a/buildSrc/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt b/buildSrc/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt
index 9dc6ea7..c0debde 100644
--- a/buildSrc/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt
+++ b/buildSrc/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt
@@ -60,6 +60,32 @@ open class FlankGradleExtension(project: Project) : FladleConfig {
 
   override var additionalTestApks: Map<String, List<String>> = emptyMap()
 
+  override var runTimeout: String? = null
+
+  override var ignoreFailedTests: Boolean = false
+
+  override var disableSharding: Boolean = false
+
+  override var smartFlankDisableUpload: Boolean = false
+
+  override var testRunnerClass: String? = null
+
+  override var localResultsDir: String? = null
+
+  override var numUniformShards: Int? = null
+
+  override var clientDetails: Map<String, String> = emptyMap()
+
+  override var testTargetsAlwaysRun: List<String> = emptyList()
+
+  override var otherFiles: Map<String, String> = emptyMap()
+
+  override var networkProfile: String? = null
+
+  override var roboScript: String? = null
+
+  override var roboDirectives: List<List<String>> = emptyList()
+
   val configs: NamedDomainObjectContainer<FladleConfigImpl> = project.container(FladleConfigImpl::class.java) {
     FladleConfigImpl(
       name = it,
@@ -84,7 +110,20 @@ open class FlankGradleExtension(project: Project) : FladleConfig {
       resultsBucket = resultsBucket,
       keepFilePath = keepFilePath,
       resultsDir = resultsDir,
-      additionalTestApks = additionalTestApks
+      additionalTestApks = additionalTestApks,
+      runTimeout = runTimeout,
+      ignoreFailedTests = ignoreFailedTests,
+      disableSharding = disableSharding,
+      smartFlankDisableUpload = smartFlankDisableUpload,
+      testRunnerClass = testRunnerClass,
+      localResultsDir = localResultsDir,
+      numUniformShards = numUniformShards,
+      clientDetails = clientDetails,
+      testTargetsAlwaysRun = testTargetsAlwaysRun,
+      otherFiles = otherFiles,
+      networkProfile = networkProfile,
+      roboScript = roboScript,
+      roboDirectives = roboDirectives
     )
   }
 
diff --git a/buildSrc/src/main/java/com/osacky/flank/gradle/YamlWriter.kt b/buildSrc/src/main/java/com/osacky/flank/gradle/YamlWriter.kt
index cdf4608..df9da59 100644
--- a/buildSrc/src/main/java/com/osacky/flank/gradle/YamlWriter.kt
+++ b/buildSrc/src/main/java/com/osacky/flank/gradle/YamlWriter.kt
@@ -30,6 +30,12 @@ internal class YamlWriter {
     val smartFlankGcsPath = config.smartFlankGcsPath
     val filesToDownload = config.filesToDownload
     val projectId = config.projectId
+    val runTimeout = config.runTimeout
+    val ignoreFailedTests = config.ignoreFailedTests
+    val disableSharding = config.disableSharding
+    val smartFlankDisableUpload = config.smartFlankDisableUpload
+    val localResultsDir = config.localResultsDir
+    val testTargetsAlwaysRun = config.testTargetsAlwaysRun
 
     appendln("flank:")
 
@@ -66,6 +72,22 @@ internal class YamlWriter {
         appendln("      test: ${it.second}")
       }
     }
+
+    runTimeout?.let {
+      appendln("  run-timeout: $it")
+    }
+    appendln("  ignore-failed-tests: $ignoreFailedTests")
+    appendln("  disable-sharding: $disableSharding")
+    appendln("  smart-flank-disable-upload: $smartFlankDisableUpload")
+    localResultsDir?.let {
+      appendln("  local-result-dir: $localResultsDir")
+    }
+    if (testTargetsAlwaysRun.isNotEmpty()) {
+      appendln("  test-targets-always-run:")
+      testTargetsAlwaysRun.forEach {
+        appendln("  - class $it")
+      }
+    }
   }
 
   internal fun writeAdditionalProperties(config: FladleConfig): String = buildString {
@@ -106,6 +128,44 @@ internal class YamlWriter {
     config.resultsDir?.let {
       appendln("  results-dir: $it")
     }
+
+    config.testRunnerClass?.let {
+      appendln("  test-runner-class: $it")
+    }
+
+    config.numUniformShards?.let {
+      appendln("  num-uniform-shards: $it")
+    }
+
+    if (config.clientDetails.isNotEmpty()) {
+      appendln("  client-details:")
+      config.clientDetails.forEach {
+        appendln("    ${it.key}: ${it.value}")
+      }
+    }
+
+    if (config.otherFiles.isNotEmpty()) {
+      appendln("  other-files:")
+      config.otherFiles.forEach {
+        appendln("    ${it.key}: ${it.value}")
+      }
+    }
+
+    config.networkProfile?.let {
+      appendln("  network-profile: $it")
+    }
+
+    config.roboScript?.let {
+      appendln("  robo-script: $it")
+    }
+
+    if (config.roboDirectives.isNotEmpty()) {
+      appendln("  robo-directives:")
+      config.roboDirectives.forEach {
+        val value = it.getOrElse(2) { "" }.let { stringValue -> if (stringValue.isBlank()) "\"\"" else stringValue }
+        appendln("    ${it[0]}:${it[1]}: $value")
+      }
+    }
   }
 
   private fun flakyTestAttemptsLine(flakyTestAttempts: Int): String {


Test patch:
diff --git a/buildSrc/src/test/java/com/osacky/flank/gradle/YamlWriterTest.kt b/buildSrc/src/test/java/com/osacky/flank/gradle/YamlWriterTest.kt
index 689899b..9314422 100644
--- a/buildSrc/src/test/java/com/osacky/flank/gradle/YamlWriterTest.kt
+++ b/buildSrc/src/test/java/com/osacky/flank/gradle/YamlWriterTest.kt
@@ -5,13 +5,14 @@ import org.gradle.testfixtures.ProjectBuilder
 import org.hamcrest.CoreMatchers.equalTo
 import org.hamcrest.MatcherAssert.assertThat
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
 import org.junit.Assert.fail
 import org.junit.Before
 import org.junit.Test
 
 class YamlWriterTest {
 
-  internal val yamlWriter = YamlWriter()
+  private val yamlWriter = YamlWriter()
 
   private lateinit var project: Project
 
@@ -134,34 +135,38 @@ class YamlWriterTest {
 
   @Test
   fun verifyMissingServiceDoesntThrowErrorIfProjectIdSet() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       projectId = "set"
       debugApk.set("path")
       instrumentationApk.set("instrument")
     }
     val yaml = yamlWriter.createConfigProps(extension, extension)
     assertEquals("gcloud:\n" +
-        "  app: path\n" +
-        "  test: instrument\n" +
-        "  device:\n" +
-        "  - model: NexusLowRes\n" +
-        "    version: 28\n" +
-        "\n" +
-        "  use-orchestrator: false\n" +
-        "  auto-google-login: false\n" +
-        "  record-video: true\n" +
-        "  performance-metrics: true\n" +
-        "  timeout: 15m\n" +
-        "  num-flaky-test-attempts: 0\n" +
-        "\n" +
-        "flank:\n" +
-        "  project: set\n" +
-        "  keep-file-path: false\n", yaml)
+            "  app: path\n" +
+            "  test: instrument\n" +
+            "  device:\n" +
+            "  - model: NexusLowRes\n" +
+            "    version: 28\n" +
+            "\n" +
+            "  use-orchestrator: false\n" +
+            "  auto-google-login: false\n" +
+            "  record-video: true\n" +
+            "  performance-metrics: true\n" +
+            "  timeout: 15m\n" +
+            "  num-flaky-test-attempts: 0\n" +
+            "\n" +
+            "flank:\n" +
+            "  project: set\n" +
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n", yaml
+    )
   }
 
   @Test
   fun verifyDebugApkThrowsError() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       serviceAccountCredentials.set(project.layout.projectDirectory.file("fake.json"))
     }
     try {
@@ -174,7 +179,7 @@ class YamlWriterTest {
 
   @Test
   fun verifyInstrumentationApkThrowsError() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       serviceAccountCredentials.set(project.layout.projectDirectory.file("fake.json"))
       debugApk.set("path")
     }
@@ -188,87 +193,112 @@ class YamlWriterTest {
 
   @Test
   fun writeNoTestShards() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
     }
 
     assertEquals("flank:\n" +
-        "  keep-file-path: false\n",
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeProjectIdOption() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       projectId = "foo"
     }
 
     assertEquals("flank:\n" +
-        "  project: foo\n" +
-        "  keep-file-path: false\n",
+            "  project: foo\n" +
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeTestShardOption() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       testShards = 5
     }
 
     assertEquals("flank:\n" +
-        "  max-test-shards: 5\n" +
-        "  keep-file-path: false\n", yamlWriter.writeFlankProperties(extension))
+            "  max-test-shards: 5\n" +
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
+            yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeShardTimeOption() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       shardTime = 120
     }
 
     assertEquals("flank:\n" +
-        "  shard-time: 120\n" +
-        "  keep-file-path: false\n",
+            "  shard-time: 120\n" +
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeNoTestRepeats() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       repeatTests = null
     }
 
     assertEquals("flank:\n" +
-        "  keep-file-path: false\n",
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeTestRepeats() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       repeatTests = 5
     }
 
     assertEquals("flank:\n" +
-        "  num-test-runs: 5\n" +
-        "  keep-file-path: false\n", yamlWriter.writeFlankProperties(extension))
+            "  num-test-runs: 5\n" +
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
+            yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeTestShardAndRepeatOption() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       testShards = 5
       repeatTests = 2
     }
 
-    assertEquals("flank:\n" +
-        "  max-test-shards: 5\n" +
-        "  num-test-runs: 2\n" +
-        "  keep-file-path: false\n", yamlWriter.writeFlankProperties(extension))
+    assertEquals(
+            "flank:\n" +
+                    "  max-test-shards: 5\n" +
+                    "  num-test-runs: 2\n" +
+                    "  keep-file-path: false\n" +
+                    "  ignore-failed-tests: false\n" +
+                    "  disable-sharding: false\n" +
+                    "  smart-flank-disable-upload: false\n",
+            yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeResultsHistoryName() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       resultsHistoryName = "androidtest"
     }
 
@@ -284,7 +314,7 @@ class YamlWriterTest {
 
   @Test
   fun writeResultsBucket() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       resultsBucket = "fake-project.appspot.com"
     }
 
@@ -300,7 +330,7 @@ class YamlWriterTest {
 
   @Test
   fun writeResultsDir() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       resultsDir = "resultsGoHere"
     }
 
@@ -316,7 +346,7 @@ class YamlWriterTest {
 
   @Test
   fun writeTestTargetsAndResultsHistoryName() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       resultsHistoryName = "androidtest"
       testTargets = listOf("class com.example.Foo")
     }
@@ -335,7 +365,7 @@ class YamlWriterTest {
 
   @Test
   fun writeNoTestTargets() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       testTargets = listOf()
     }
 
@@ -349,7 +379,7 @@ class YamlWriterTest {
 
   @Test
   fun writeSingleTestTargets() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       testTargets = listOf("class com.example.Foo#testThing")
     }
 
@@ -366,7 +396,7 @@ class YamlWriterTest {
 
   @Test
   fun writeMultipleTestTargets() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       testTargets = listOf("class com.example.Foo#testThing", "class com.example.Foo#testThing2")
     }
 
@@ -384,19 +414,22 @@ class YamlWriterTest {
 
   @Test
   fun writeSmartFlankGcsPath() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       smartFlankGcsPath = "gs://test/fakepath.xml"
     }
 
     assertEquals("flank:\n" +
-        "  smart-flank-gcs-path: gs://test/fakepath.xml\n" +
-        "  keep-file-path: false\n",
+            "  smart-flank-gcs-path: gs://test/fakepath.xml\n" +
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
         yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeNoDirectoriesToPull() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       directoriesToPull = listOf()
     }
 
@@ -410,7 +443,7 @@ class YamlWriterTest {
 
   @Test
   fun writeSingleDirectoriesToPull() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       directoriesToPull = listOf("/sdcard/screenshots")
     }
 
@@ -427,7 +460,7 @@ class YamlWriterTest {
 
   @Test
   fun writeMultipleDirectoriesToPull() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       directoriesToPull = listOf("/sdcard/screenshots", "/sdcard/reports")
     }
 
@@ -445,31 +478,37 @@ class YamlWriterTest {
 
   @Test
   fun writeNoFilesToDownload() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       filesToDownload = listOf()
     }
 
     assertEquals("flank:\n" +
-        "  keep-file-path: false\n",
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeSingleFilesToDownload() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       filesToDownload = listOf(".*/screenshots/.*")
     }
 
     assertEquals("flank:\n" +
-        "  keep-file-path: false\n" +
-        "  files-to-download:\n" +
-        "  - .*/screenshots/.*\n",
+            "  keep-file-path: false\n" +
+            "  files-to-download:\n" +
+            "  - .*/screenshots/.*\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeMultipleFilesToDownload() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       filesToDownload = listOf(".*/screenshots/.*", ".*/reports/.*")
     }
 
@@ -477,13 +516,16 @@ class YamlWriterTest {
         "  keep-file-path: false\n" +
         "  files-to-download:\n" +
         "  - .*/screenshots/.*\n" +
-        "  - .*/reports/.*\n",
+        "  - .*/reports/.*\n" +
+        "  ignore-failed-tests: false\n" +
+        "  disable-sharding: false\n" +
+        "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeSingleEnvironmentVariables() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       environmentVariables = mapOf(
         "listener" to "com.osacky.flank.sample.Listener"
       )
@@ -502,7 +544,7 @@ class YamlWriterTest {
 
   @Test
   fun writeMultipleEnvironmentVariables() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       environmentVariables = mapOf(
         "clearPackageData" to "true",
         "listener" to "com.osacky.flank.sample.Listener"
@@ -523,7 +565,7 @@ class YamlWriterTest {
 
   @Test
   fun writeDefaultProperties() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       useOrchestrator = true
       autoGoogleLogin = true
       recordVideo = false
@@ -545,24 +587,30 @@ class YamlWriterTest {
     val extension = FlankGradleExtension(project)
 
     assertEquals("flank:\n" +
-        "  keep-file-path: false\n",
+            "  keep-file-path: false\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeKeepFilePath() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       keepFilePath = true
     }
 
     assertEquals("flank:\n" +
-        "  keep-file-path: true\n",
+            "  keep-file-path: true\n" +
+            "  ignore-failed-tests: false\n" +
+            "  disable-sharding: false\n" +
+            "  smart-flank-disable-upload: false\n",
       yamlWriter.writeFlankProperties(extension))
   }
 
   @Test
   fun writeAdditionalTestApks() {
-    val extension = FlankGradleExtension(project).apply {
+    val extension = emptyExtension {
       debugApk.set("../orange/build/output/app.apk")
       instrumentationApk.set("../orange/build/output/app-test.apk")
       additionalTestApks = mapOf(
@@ -580,8 +628,190 @@ class YamlWriterTest {
         "    - app: ../orange/build/output/app.apk\n" +
         "      test: ../orange/build/output/app-test2.apk\n" +
         "    - app: ../bob/build/output/app.apk\n" +
-        "      test: ../bob/build/output/app-test.apk\n"
+        "      test: ../bob/build/output/app-test.apk\n" +
+        "  ignore-failed-tests: false\n" +
+        "  disable-sharding: false\n" +
+        "  smart-flank-disable-upload: false\n"
       )
     )
   }
+
+  @Test
+  fun verifyDefaultValues() {
+    val defaultFlankProperties = FlankGradleExtension(project).toFlankProperties()
+    val defaultAdditionalProperties = FlankGradleExtension(project).toAdditionalProperties().trimIndent()
+
+    val expectedFlank = """
+      flank:
+        keep-file-path: false
+        ignore-failed-tests: false
+        disable-sharding: false
+        smart-flank-disable-upload: false
+    """.trimIndent()
+
+    val expectedAdditional = """
+        use-orchestrator: false
+        auto-google-login: false
+        record-video: true
+        performance-metrics: true
+        timeout: 15m
+        num-flaky-test-attempts: 0
+    """.trimIndent()
+
+    assertEquals(expectedFlank, defaultFlankProperties)
+    assertEquals(expectedAdditional, defaultAdditionalProperties)
+  }
+
+  @Test
+  fun writeRunTimeout() {
+    val extension = emptyExtension {
+      runTimeout = "20m"
+    }
+
+    assertTrue(yamlWriter.writeFlankProperties(extension).contains("  run-timeout: 20m"))
+  }
+
+  @Test
+  fun writeIgnoreFailedTests() {
+    val properties = emptyExtension {
+      ignoreFailedTests = true
+    }.toFlankProperties()
+
+    assertTrue(properties.contains("  ignore-failed-tests: true"))
+  }
+
+  @Test
+  fun writeDisableSharding() {
+    val properties = emptyExtension {
+      disableSharding = true
+    }.toFlankProperties()
+
+    assertTrue(properties.contains("  disable-sharding: true"))
+  }
+
+  @Test
+  fun writeSmartFlankDisableUpload() {
+    val properties = emptyExtension {
+      smartFlankDisableUpload = true
+    }.toFlankProperties()
+
+    assertTrue(properties.contains("  smart-flank-disable-upload: true"))
+  }
+
+  @Test
+  fun writeTestRunnerClass() {
+    val properties = emptyExtension {
+      testRunnerClass = "any.class.Runner"
+    }.toAdditionalProperties()
+
+    assertTrue(properties.contains("  test-runner-class: any.class.Runner"))
+  }
+
+  @Test
+  fun writeLocalResultsDir() {
+    val properties = emptyExtension {
+      localResultsDir = "~/my/results/dir"
+    }.toFlankProperties()
+
+    assertTrue(properties.contains("  local-result-dir: ~/my/results/dir"))
+  }
+
+  @Test
+  fun writeNumUniformShards() {
+    val properties = emptyExtension {
+      numUniformShards = 20
+    }.toAdditionalProperties()
+
+    assertTrue(properties.contains("  num-uniform-shards: 20"))
+  }
+
+  @Test
+  fun writeClientDetails() {
+    val properties = emptyExtension {
+      clientDetails = mapOf(
+              "anyDetail1" to "anyValue1",
+              "anyDetail2" to "anyValue2"
+      )
+    }.toAdditionalProperties()
+
+    assertTrue(properties.contains("""
+      |  client-details:
+      |    anyDetail1: anyValue1
+      |    anyDetail2: anyValue2
+    """.trimMargin()))
+  }
+
+  @Test
+  fun writeTestTargetsAlwaysRun() {
+    val properties = emptyExtension {
+      testTargetsAlwaysRun = listOf(
+              "com.example.FirstTests#test1",
+              "com.example.FirstTests#test2",
+              "com.example.FirstTests#test3"
+      )
+    }.toFlankProperties()
+
+    assertTrue(properties.contains("""
+      |  test-targets-always-run:
+      |  - class com.example.FirstTests#test1
+      |  - class com.example.FirstTests#test2
+      |  - class com.example.FirstTests#test3
+    """.trimMargin()))
+  }
+
+  @Test
+  fun writeOtherFiles() {
+    val properties = emptyExtension {
+      otherFiles = mapOf(
+              "/example/path/test1" to "anyfile.txt",
+              "/example/path/test2" to "anyfile2.txt"
+      )
+    }.toAdditionalProperties()
+
+    assertTrue(properties.contains("""
+        |  other-files:
+        |    /example/path/test1: anyfile.txt
+        |    /example/path/test2: anyfile2.txt
+    """.trimMargin()))
+  }
+
+  @Test
+  fun writeNetworkProfile() {
+    val properties = emptyExtension {
+      networkProfile = "LTE"
+    }.toAdditionalProperties()
+
+    assertTrue(properties.contains("  network-profile: LTE"))
+  }
+
+  @Test
+  fun writeRoboScript() {
+    val properties = emptyExtension {
+      roboScript = "~/my/dir/with/script.json"
+    }.toAdditionalProperties()
+
+    assertTrue(properties.contains("  robo-script: ~/my/dir/with/script.json"))
+  }
+
+  @Test
+  fun writeRoboDirectives() {
+    val properties = emptyExtension {
+      roboDirectives = listOf(
+              listOf("click", "button3"),
+              listOf("ignore", "button1", ""),
+              listOf("text", "field1", "my common text")
+      )
+    }.toAdditionalProperties()
+
+    assertTrue(properties.contains("""
+        |  robo-directives:
+        |    click:button3: ""
+        |    ignore:button1: ""
+        |    text:field1: my common text
+    """.trimMargin()))
+  }
+
+  private fun emptyExtension(block: FlankGradleExtension.() -> Unit) = FlankGradleExtension(project).apply(block)
+  private fun FlankGradleExtension.toFlankProperties() = yamlWriter.writeFlankProperties(this).trimIndent()
+  private fun FlankGradleExtension.toAdditionalProperties() = yamlWriter.writeAdditionalProperties(this)
 }