Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add new assertVisual command #2078

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,25 @@ data class AssertWithAICommand(
}
}

data class AssertVisualCommand(
val baseline: String,
val thresholdPercentage: Int,
override val optional: Boolean = false,
override val label: String? = null,
) : Command {
override fun description(): String {
return label ?: "Assert visual difference with baseline $baseline (threshold: $thresholdPercentage%)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a nitpick, I know, but thinking of terminal in a vscode window on a MacBook... Could this be shorter?

Perhaps not 🤔

Copy link
Contributor

@bartekpacia bartekpacia Oct 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideas:

  • visual difference -> visual diff maybe?
  • display just filename of $baseline, not the whole path

}

override fun evaluateScripts(jsEngine: JsEngine): Command {
return copy(
baseline = baseline.evaluateScripts(jsEngine)
)
}
}



data class InputTextCommand(
val text: String,
override val label: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 assertNoDefectsWithAICommand: AssertNoDefectsWithAICommand? = null,
val assertWithAICommand: AssertWithAICommand? = null,
val inputTextCommand: InputTextCommand? = null,
Expand Down Expand Up @@ -82,6 +83,7 @@ data class MaestroCommand(
assertWithAICommand = command as? AssertWithAICommand,
inputTextCommand = command as? InputTextCommand,
inputRandomTextCommand = command as? InputRandomCommand,
assertVisualCommand = command as? AssertVisualCommand,
launchAppCommand = command as? LaunchAppCommand,
applyConfigurationCommand = command as? ApplyConfigurationCommand,
openLinkCommand = command as? OpenLinkCommand,
Expand Down Expand Up @@ -137,6 +139,7 @@ data class MaestroCommand(
clearKeychainCommand != null -> clearKeychainCommand
runFlowCommand != null -> runFlowCommand
setLocationCommand != null -> setLocationCommand
assertVisualCommand != null -> assertVisualCommand
repeatCommand != null -> repeatCommand
copyTextCommand != null -> copyTextCommand
pasteTextCommand != null -> pasteTextCommand
Expand Down
55 changes: 55 additions & 0 deletions maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

package maestro.orchestra

import com.github.romankh3.image.comparison.ImageComparison
import com.github.romankh3.image.comparison.model.ImageComparisonState
import kotlinx.coroutines.runBlocking
import maestro.*
import maestro.Filters.asFilter
Expand Down Expand Up @@ -46,8 +48,15 @@ import okio.Buffer
import okio.Sink
import okio.buffer
import okio.sink
import java.awt.image.BufferedImage
import java.io.File
import java.io.IOException
import java.lang.Long.max
import java.nio.file.Files
import java.nio.file.Paths
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.imageio.ImageIO

// TODO(bartkepacia): Use this in onCommandGeneratedOutput.
// Caveat:
Expand Down Expand Up @@ -267,6 +276,7 @@ class Orchestra(
is PasteTextCommand -> pasteText()
is SwipeCommand -> swipeCommand(command)
is AssertCommand -> assertCommand(command)
is AssertVisualCommand -> assertVisualCommand(command)
is AssertConditionCommand -> assertConditionCommand(command)
is AssertNoDefectsWithAICommand -> assertNoDefectsWithAICommand(command)
is AssertWithAICommand -> assertWithAICommand(command)
Expand Down Expand Up @@ -406,6 +416,51 @@ class Orchestra(
false
}

private fun assertVisualCommand(command: AssertVisualCommand): Boolean {
val baseline = command.baseline + ".png"
val thresholdDifferencePercentage = (100 - command.thresholdPercentage).toDouble()

val screenshotsDir = File(".maestro/visual_regression").apply { mkdirs() }

val actual = File(screenshotsDir, baseline)

val expected = File
.createTempFile("screenshot-${System.currentTimeMillis()}", ".png")
.also { it.deleteOnExit() }

maestro.takeScreenshot(expected.sink(), false)

if (!actual.exists()) {
expected.copyTo(actual, overwrite = true)
return true
}

val photoNow: BufferedImage = ImageIO.read(expected)
val oldPhoto: BufferedImage = ImageIO.read(actual)
val failedRegressionDir = File(".maestro/failed_visual_regression").apply { mkdirs() }
val regressionFailedFile = File(failedRegressionDir, baseline)
val comparison =
ImageComparison(photoNow, oldPhoto, regressionFailedFile)

comparison.apply {
allowingPercentOfDifferentPixels = thresholdDifferencePercentage
rectangleLineWidth = 10
pixelToleranceLevel = 50.00
minimalRectangleSize = 40
}

val comparisonState = comparison.compareImages()

if (ImageComparisonState.MISMATCH === comparisonState.imageComparisonState) {
throw MaestroException.AssertionFailure(
message = "Comparison error: ${command.description()} - threshold not met, current: ${100 - comparisonState.differencePercent}",
hierarchyRoot = maestro.viewHierarchy().root,
)
}
return true
}


private fun evalScriptCommand(command: EvalScriptCommand): Boolean {
command.scriptString.evaluateScripts(jsEngine)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package maestro.orchestra.yaml

import com.fasterxml.jackson.annotation.JsonCreator
import java.lang.UnsupportedOperationException

private const val DEFAULT_DIFF_THRESHOLD = 95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be exposed as an environment variable (assuming you don't want to go as far as to expose it to workspace config at this point)?


data class YamlAssertVisual(
val baseline: String,
val thresholdPercentage: Int = DEFAULT_DIFF_THRESHOLD,
val label: String? = null,
val optional: Boolean = false,
) {

companion object {
@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun parse(baseline: String): YamlAssertVisual {
return YamlAssertVisual(
baseline = baseline
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ data class YamlFluentCommand(
val assertNotVisible: YamlElementSelectorUnion? = null,
val assertTrue: YamlAssertTrue? = null,
val assertNoDefectsWithAI: YamlAssertNoDefectsWithAI? = null,
val assertVisual: YamlAssertVisual? = null,
val assertWithAI: YamlAssertWithAI? = null,
val back: YamlActionBack? = null,
val clearKeychain: YamlActionClearKeychain? = null,
Expand Down Expand Up @@ -137,6 +138,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)
Expand Down
Loading