diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt index 0ba2d0466a..075ebbe8df 100644 --- a/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt @@ -364,6 +364,24 @@ data class AssertConditionCommand( } } +data class AssertVisualCommand( + val baseline: String, + val thresholdPercentage: Int, // from 1 to 100 + val optional: Boolean = false, /// If true, the command will not fail the flow, but print a warning + val label: String? = null, +) : Command { + override fun description(): String { + return label ?: "Assert visual difference with baseline $baseline (threshold: $thresholdPercentage%)" + } + + override fun evaluateScripts(jsEngine: JsEngine): Command { + return copy( + baseline = baseline.evaluateScripts(jsEngine) + // TODO: Allow for evaluating script for more properties + ) + } +} + data class InputTextCommand( val text: String, val label: String? = null, @@ -920,6 +938,7 @@ data class ToggleAirplaneModeCommand( } } + internal fun tapOnDescription(isLongPress: Boolean?, repeat: TapRepeat?): String { return if (isLongPress == true) "Long press" else if (repeat != null) { diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt index 3d4fc439d4..d4eece2165 100644 --- a/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt @@ -36,6 +36,7 @@ data class MaestroCommand( val backPressCommand: BackPressCommand? = null, @Deprecated("Use assertConditionCommand") val assertCommand: AssertCommand? = null, val assertConditionCommand: AssertConditionCommand? = null, + val assertVisualCommand: AssertVisualCommand? = null, val inputTextCommand: InputTextCommand? = null, val inputRandomTextCommand: InputRandomCommand? = null, val launchAppCommand: LaunchAppCommand? = null, @@ -75,6 +76,7 @@ data class MaestroCommand( swipeCommand = command as? SwipeCommand, backPressCommand = command as? BackPressCommand, assertCommand = command as? AssertCommand, + assertVisualCommand = command as? AssertVisualCommand, assertConditionCommand = command as? AssertConditionCommand, inputTextCommand = command as? InputTextCommand, inputRandomTextCommand = command as? InputRandomCommand, @@ -116,6 +118,7 @@ data class MaestroCommand( backPressCommand != null -> backPressCommand assertCommand != null -> assertCommand assertConditionCommand != null -> assertConditionCommand + assertVisualCommand != null -> assertVisualCommand inputTextCommand != null -> inputTextCommand inputRandomTextCommand != null -> inputRandomTextCommand launchAppCommand != null -> launchAppCommand diff --git a/maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt b/maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt index a8e0bf4e27..bccd290ef9 100644 --- a/maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt +++ b/maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt @@ -7,6 +7,7 @@ import maestro.orchestra.MaestroCommand object Env { fun String.evaluateScripts(jsEngine: JsEngine): String { + // Look for strings starting with ${ and ending with } val result = "(? val script = match.groups[1]?.value ?: "" diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt index 9adec62399..42f0e47836 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt @@ -19,6 +19,7 @@ package maestro.orchestra +import com.github.romankh3.image.comparison.ImageComparison import maestro.* import maestro.Filters.asFilter import maestro.js.GraalJsEngine @@ -256,6 +257,7 @@ class Orchestra( is SwipeCommand -> swipeCommand(command) is AssertCommand -> assertCommand(command) is AssertConditionCommand -> assertConditionCommand(command) + is AssertVisualCommand -> assertVisualCommand(command) is InputTextCommand -> inputTextCommand(command) is InputRandomCommand -> inputTextRandomCommand(command) is LaunchAppCommand -> launchAppCommand(command) @@ -334,6 +336,34 @@ class Orchestra( return false } + private fun assertVisualCommand(command: AssertVisualCommand): Boolean { + val baseline = command.baseline + val thresholdPercentage = command.thresholdPercentage + + val file = screenshotsDir + ?.let { File(it, baseline) } + ?: File("actual_screenshots") + + val actual = maestro.takeScreenshot() + + val imageDiff = ImageComparison( + startScreenshot, + endScreenshot + ).compareImages().differencePercent + + maestro.takeScreenshot(file, false) + + if (!actual.matches(expected)) { + throw MaestroException.VisualAssertionFailure( + "Visual assertion failed: ${command.selector.description()}", + expected, + actual, + ) + } + + return false + } + private fun isOptional(condition: Condition): Boolean { return condition.visible?.optional == true || condition.notVisible?.optional == true diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAssertVisual.kt b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAssertVisual.kt new file mode 100644 index 0000000000..878e2f1c64 --- /dev/null +++ b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAssertVisual.kt @@ -0,0 +1,30 @@ +package maestro.orchestra.yaml + +import com.fasterxml.jackson.annotation.JsonCreator + +private const val DEFAULT_DIFF_THRESHOLD = 95 + +data class YamlAssertVisual( + val baseline: String, + val thresholdPercentage: Int = DEFAULT_DIFF_THRESHOLD, + val optional: Boolean = false, + val label: String? = null, +) { + + companion object { + + // TODO(bartek): This might be needed if single value is passed in YAML + // @JvmStatic + // @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + // fun parse(appId: String): YamlLaunchApp { + // return YamlLaunchApp( + // appId = appId, + // clearState = null, + // clearKeychain = null, + // stopApp = null, + // permissions = null, + // arguments = null, + // ) + // } + } +} diff --git a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt index 4362043416..f43e3dcd20 100644 --- a/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt +++ b/maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt @@ -41,6 +41,7 @@ data class YamlFluentCommand( val assertVisible: YamlElementSelectorUnion? = null, val assertNotVisible: YamlElementSelectorUnion? = null, val assertTrue: YamlAssertTrue? = null, + val assertVisual: YamlAssertVisual? = null, val back: YamlActionBack? = null, val clearKeychain: YamlActionClearKeychain? = null, val hideKeyboard: YamlActionHideKeyboard? = null, @@ -115,6 +116,16 @@ data class YamlFluentCommand( ) ) ) + assertVisual != null -> listOf( + MaestroCommand( + AssertVisualCommand( + baseline = assertVisual.baseline, + thresholdPercentage = assertVisual.thresholdPercentage, + optional = assertVisual.optional, + label = assertVisual.label + ) + ) + ) addMedia != null -> listOf( MaestroCommand( addMediaCommand = addMediaCommand(addMedia, flowPath)