From 43229ed137334ea7b426f254f3b5e5df30095a63 Mon Sep 17 00:00:00 2001 From: Magnus von Scheele Date: Wed, 14 Feb 2024 11:56:02 +0100 Subject: [PATCH] Add a way to provide a file name for Snapshots Adds a FileNameProvider that can be implemented in any way to specify file names for a recorded Snapshot. Closes feature request #549 --- paparazzi/api/paparazzi.api | 23 ++++++++---- .../app/cash/paparazzi/FileNameProvider.kt | 24 ++++++++++++ .../app/cash/paparazzi/HtmlReportWriter.kt | 20 +++++++--- .../main/java/app/cash/paparazzi/Paparazzi.kt | 18 ++++++--- .../main/java/app/cash/paparazzi/Snapshot.kt | 13 ------- .../app/cash/paparazzi/SnapshotVerifier.kt | 6 ++- .../cash/paparazzi/HtmlReportWriterTest.kt | 37 +++++++++++++++++++ 7 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 paparazzi/src/main/java/app/cash/paparazzi/FileNameProvider.kt diff --git a/paparazzi/api/paparazzi.api b/paparazzi/api/paparazzi.api index 573a772363..d824c72ab8 100644 --- a/paparazzi/api/paparazzi.api +++ b/paparazzi/api/paparazzi.api @@ -85,6 +85,10 @@ public final class app/cash/paparazzi/EnvironmentKt { public static final fun detectEnvironment ()Lapp/cash/paparazzi/Environment; } +public abstract interface class app/cash/paparazzi/FileNameProvider { + public abstract fun snapshotFileName (Lapp/cash/paparazzi/Snapshot;Ljava/lang/String;)Ljava/lang/String; +} + public final class app/cash/paparazzi/Flags { public static final field $stable I public static final field DEBUG_LINKED_OBJECTS Ljava/lang/String; @@ -97,7 +101,8 @@ public final class app/cash/paparazzi/HtmlReportWriter : app/cash/paparazzi/Snap public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/io/File;)V public fun (Ljava/lang/String;Ljava/io/File;Ljava/io/File;)V - public synthetic fun (Ljava/lang/String;Ljava/io/File;Ljava/io/File;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/io/File;Ljava/io/File;Lapp/cash/paparazzi/FileNameProvider;)V + public synthetic fun (Ljava/lang/String;Ljava/io/File;Ljava/io/File;Lapp/cash/paparazzi/FileNameProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V public fun newFrameHandler (Lapp/cash/paparazzi/Snapshot;II)Lapp/cash/paparazzi/SnapshotHandler$FrameHandler; } @@ -117,12 +122,13 @@ public final class app/cash/paparazzi/Paparazzi : org/junit/rules/TestRule { public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;)V public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;Z)V public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZD)V - public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/SnapshotHandler;)V - public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;)V - public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;Z)V - public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;ZZ)V - public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;ZZZ)V - public synthetic fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/FileNameProvider;)V + public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/FileNameProvider;Lapp/cash/paparazzi/SnapshotHandler;)V + public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/FileNameProvider;Lapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;)V + public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/FileNameProvider;Lapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;Z)V + public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/FileNameProvider;Lapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;ZZ)V + public fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/FileNameProvider;Lapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;ZZZ)V + public synthetic fun (Lapp/cash/paparazzi/Environment;Lapp/cash/paparazzi/DeviceConfig;Ljava/lang/String;Lcom/android/ide/common/rendering/api/SessionParams$RenderingMode;ZDLapp/cash/paparazzi/FileNameProvider;Lapp/cash/paparazzi/SnapshotHandler;Ljava/util/Set;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; public final fun close ()V public final fun getContext ()Landroid/content/Context; @@ -178,7 +184,8 @@ public final class app/cash/paparazzi/SnapshotVerifier : app/cash/paparazzi/Snap public static final field $stable I public fun (D)V public fun (DLjava/io/File;)V - public synthetic fun (DLjava/io/File;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (DLjava/io/File;Lapp/cash/paparazzi/FileNameProvider;)V + public synthetic fun (DLjava/io/File;Lapp/cash/paparazzi/FileNameProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V public fun newFrameHandler (Lapp/cash/paparazzi/Snapshot;II)Lapp/cash/paparazzi/SnapshotHandler$FrameHandler; } diff --git a/paparazzi/src/main/java/app/cash/paparazzi/FileNameProvider.kt b/paparazzi/src/main/java/app/cash/paparazzi/FileNameProvider.kt new file mode 100644 index 0000000000..e45fff68da --- /dev/null +++ b/paparazzi/src/main/java/app/cash/paparazzi/FileNameProvider.kt @@ -0,0 +1,24 @@ +package app.cash.paparazzi + +import java.util.Locale + +public interface FileNameProvider { + public fun snapshotFileName(snapshot: Snapshot, extension: String): String +} + +internal class DefaultFileNameProvider( + private val delimiter: String = "_" +) : FileNameProvider { + + override fun snapshotFileName(snapshot: Snapshot, extension: String): String { + val name = snapshot.name + val formattedLabel = if (name != null) { + "$delimiter${name.lowercase(Locale.US).replace("\\s".toRegex(), delimiter)}" + } else { + "" + } + + val testName = snapshot.testName + return "${testName.packageName}${delimiter}${testName.className}${delimiter}${testName.methodName}$formattedLabel.$extension" + } +} diff --git a/paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt b/paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt index 81eb5df0fe..58000d1e3a 100644 --- a/paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt +++ b/paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt @@ -60,7 +60,8 @@ import javax.imageio.ImageIO public class HtmlReportWriter @JvmOverloads constructor( private val runName: String = defaultRunName(), private val rootDirectory: File = File(System.getProperty("paparazzi.report.dir")), - snapshotRootDirectory: File = File(System.getProperty("paparazzi.snapshot.dir")) + snapshotRootDirectory: File = File(System.getProperty("paparazzi.snapshot.dir")), + private val fileNameProvider: FileNameProvider = DefaultFileNameProvider() ) : SnapshotHandler { private val runsDirectory: File = File(rootDirectory, "runs") private val imagesDirectory: File = File(rootDirectory, "images") @@ -101,7 +102,10 @@ public class HtmlReportWriter @JvmOverloads constructor( val shot = if (hashes.size == 1) { val original = File(imagesDirectory, "${hashes[0]}.png") if (isRecording) { - val goldenFile = File(goldenImagesDirectory, snapshot.toFileName("_", "png")) + val goldenFile = File( + goldenImagesDirectory, + fileNameProvider.snapshotFileName(snapshot, extension = "png") + ) original.copyTo(goldenFile, overwrite = true) } snapshot.copy(file = original.toJsonPath()) @@ -112,7 +116,10 @@ public class HtmlReportWriter @JvmOverloads constructor( for ((index, frameHash) in hashes.withIndex()) { val originalFrame = File(imagesDirectory, "$frameHash.png") val frameSnapshot = snapshot.copy(name = "${snapshot.name} $index") - val goldenFile = File(goldenImagesDirectory, frameSnapshot.toFileName("_", "png")) + val goldenFile = File( + goldenImagesDirectory, + fileNameProvider.snapshotFileName(frameSnapshot, extension = "png") + ) if (!goldenFile.exists()) { originalFrame.copyTo(goldenFile) } @@ -120,7 +127,10 @@ public class HtmlReportWriter @JvmOverloads constructor( } val original = File(videosDirectory, "$hash.mov") if (isRecording) { - val goldenFile = File(goldenVideosDirectory, snapshot.toFileName("_", "mov")) + val goldenFile = File( + goldenVideosDirectory, + fileNameProvider.snapshotFileName(snapshot, extension = "mov") + ) if (!goldenFile.exists()) { original.copyTo(goldenFile) } @@ -290,5 +300,5 @@ internal val filenameSafeChars = CharMatcher.inRange('a', 'z') .or(CharMatcher.anyOf("_-.~@^()[]{}:;,")) internal fun String.sanitizeForFilename(): String? { - return filenameSafeChars.negate().replaceFrom(toLowerCase(Locale.US), '_') + return filenameSafeChars.negate().replaceFrom(lowercase(Locale.US), '_') } diff --git a/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt b/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt index 655f87c3c6..b4a202cbd5 100644 --- a/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt +++ b/paparazzi/src/main/java/app/cash/paparazzi/Paparazzi.kt @@ -95,7 +95,11 @@ public class Paparazzi @JvmOverloads constructor( private val renderingMode: RenderingMode = RenderingMode.NORMAL, private val appCompatEnabled: Boolean = true, private val maxPercentDifference: Double = 0.1, - private val snapshotHandler: SnapshotHandler = determineHandler(maxPercentDifference), + private val fileNameProvider: FileNameProvider = DefaultFileNameProvider(), + private val snapshotHandler: SnapshotHandler = determineHandler( + maxPercentDifference, + fileNameProvider + ), private val renderExtensions: Set = setOf(), private val supportsRtl: Boolean = false, private val showSystemUi: Boolean = false, @@ -675,11 +679,15 @@ public class Paparazzi @JvmOverloads constructor( } } - private fun determineHandler(maxPercentDifference: Double): SnapshotHandler = - if (isVerifying) { - SnapshotVerifier(maxPercentDifference) + private fun determineHandler( + maxPercentDifference: Double, + fileNameProvider: FileNameProvider + ): SnapshotHandler { + return if (isVerifying) { + SnapshotVerifier(maxPercentDifference, fileNameProvider = fileNameProvider) } else { - HtmlReportWriter() + HtmlReportWriter(fileNameProvider = fileNameProvider) } + } } } diff --git a/paparazzi/src/main/java/app/cash/paparazzi/Snapshot.kt b/paparazzi/src/main/java/app/cash/paparazzi/Snapshot.kt index f49f3b9bad..2d27928a48 100644 --- a/paparazzi/src/main/java/app/cash/paparazzi/Snapshot.kt +++ b/paparazzi/src/main/java/app/cash/paparazzi/Snapshot.kt @@ -17,7 +17,6 @@ package app.cash.paparazzi import dev.drewhamilton.poko.Poko import java.util.Date -import java.util.Locale @Poko public class Snapshot( @@ -35,15 +34,3 @@ public class Snapshot( file: String? = this.file ): Snapshot = Snapshot(name, testName, timestamp, tags, file) } - -internal fun Snapshot.toFileName( - delimiter: String = "_", - extension: String -): String { - val formattedLabel = if (name != null) { - "$delimiter${name.toLowerCase(Locale.US).replace("\\s".toRegex(), delimiter)}" - } else { - "" - } - return "${testName.packageName}${delimiter}${testName.className}${delimiter}${testName.methodName}$formattedLabel.$extension" -} diff --git a/paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt b/paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt index b5744bf494..7734074ac4 100644 --- a/paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt +++ b/paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt @@ -23,7 +23,8 @@ import javax.imageio.ImageIO public class SnapshotVerifier @JvmOverloads constructor( private val maxPercentDifference: Double, - rootDirectory: File = File(System.getProperty("paparazzi.snapshot.dir")) + rootDirectory: File = File(System.getProperty("paparazzi.snapshot.dir")), + private val fileNameProvider: FileNameProvider = DefaultFileNameProvider() ) : SnapshotHandler { private val imagesDirectory: File = File(rootDirectory, "images") private val videosDirectory: File = File(rootDirectory, "videos") @@ -41,7 +42,8 @@ public class SnapshotVerifier @JvmOverloads constructor( return object : FrameHandler { override fun handle(image: BufferedImage) { // Note: does not handle videos or its frames at the moment - val expected = File(imagesDirectory, snapshot.toFileName(extension = "png")) + val expected = + File(imagesDirectory, fileNameProvider.snapshotFileName(snapshot, extension = "png")) if (!expected.exists()) { throw AssertionError("File $expected does not exist") } diff --git a/paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt b/paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt index e6694b82e2..602d38875c 100644 --- a/paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt +++ b/paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt @@ -158,6 +158,43 @@ class HtmlReportWriterTest { } } + @Test + fun useFileNameProvider() { + // set record mode + System.setProperty("paparazzi.test.record", "true") + + val htmlReportWriter = HtmlReportWriter( + "record_run", + fileNameProvider = object : FileNameProvider { + override fun snapshotFileName(snapshot: Snapshot, extension: String): String { + return "${snapshot.name}.$extension" + } + }, + rootDirectory = reportRoot.root, + snapshotRootDirectory = snapshotRoot.root + ) + htmlReportWriter.use { + val snapshot = Snapshot( + name = "test", + testName = TestName("app.cash.paparazzi", "HomeView", "testSettings"), + timestamp = Instant.parse("2021-02-23T10:27:43Z").toDate() + ) + val golden = File("${snapshotRoot.root}/images/test.png") + + // precondition + assertThat(golden).doesNotExist() + + // take 1 + val frameHandler1 = htmlReportWriter.newFrameHandler( + snapshot = snapshot, + frameCount = 1, + fps = -1 + ) + frameHandler1.use { frameHandler1.handle(anyImage) } + assertThat(golden).exists() + } + } + private fun Instant.toDate() = Date(toEpochMilli()) private fun File.lastModifiedTime(): FileTime {