Skip to content
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
1 change: 1 addition & 0 deletions paparazzi/api/paparazzi.api
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public final class app/cash/paparazzi/EnvironmentKt {

public final class app/cash/paparazzi/Flags {
public static final field $stable I
public static final field ACCESSIBILITY_HIERARCHY_ARTIFACTS_ENABLED Ljava/lang/String;
public static final field DEBUG_LINKED_OBJECTS Ljava/lang/String;
public static final field INSTANCE Lapp/cash/paparazzi/Flags;
}
Expand Down
21 changes: 21 additions & 0 deletions paparazzi/src/main/java/app/cash/paparazzi/ArtifactPaths.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package app.cash.paparazzi

import java.io.File

internal const val ARTIFACTS_DIRECTORY_NAME = "artifacts"

internal fun Snapshot.artifactFile(artifactName: String, artifactsDirectory: File): File {
val (artifactDirectoryName, artifactExtension) = artifactName.toArtifactPathParts()
return File(
File(artifactsDirectory, artifactDirectoryName),
toFileName("_", artifactExtension)
)
}

private fun String.toArtifactPathParts(): Pair<String, String> {
val rawExtension = substringAfterLast('.', "")
val extension = rawExtension.sanitizeForFilename().ifBlank { "txt" }
val rawDirectoryName = if (rawExtension.isBlank()) this else substringBeforeLast('.')
val directoryName = rawDirectoryName.sanitizeForFilename().ifBlank { "artifact" }
return directoryName to extension
}
2 changes: 2 additions & 0 deletions paparazzi/src/main/java/app/cash/paparazzi/Flags.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ package app.cash.paparazzi

public object Flags {
public const val DEBUG_LINKED_OBJECTS: String = "app.cash.paparazzi.debug.linked.objects"
public const val ACCESSIBILITY_HIERARCHY_ARTIFACTS_ENABLED: String =
"app.cash.paparazzi.accessibility.hierarchy.artifacts.enabled"
}
20 changes: 20 additions & 0 deletions paparazzi/src/main/java/app/cash/paparazzi/HtmlReportWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ public class HtmlReportWriter @JvmOverloads constructor(
private val runsDirectory: File = File(rootDirectory, "runs")
private val imagesDirectory: File = File(rootDirectory, "images")
private val videosDirectory: File = File(rootDirectory, "videos")
private val reportArtifactsDirectory = File(rootDirectory, ARTIFACTS_DIRECTORY_NAME)

private val goldenImagesDirectory = File(snapshotRootDirectory, "images")
private val goldenVideosDirectory = File(snapshotRootDirectory, "videos")
private val goldenArtifactsDirectory = File(snapshotRootDirectory, ARTIFACTS_DIRECTORY_NAME)

private val shots = mutableListOf<Snapshot>()

Expand All @@ -85,6 +87,8 @@ public class HtmlReportWriter @JvmOverloads constructor(
runsDirectory.mkdirs()
imagesDirectory.mkdirs()
videosDirectory.mkdirs()
reportArtifactsDirectory.mkdirs()
goldenArtifactsDirectory.mkdirs()
writeStaticFiles()
writeRunJs()
writeIndexJs()
Expand All @@ -103,6 +107,22 @@ public class HtmlReportWriter @JvmOverloads constructor(
hashes += hash(image)
}

override fun handleArtifact(name: String, content: String) {
val artifactFile = snapshot.artifactFile(name, reportArtifactsDirectory)
artifactFile.parentFile.mkdirs()
artifactFile.writeAtomically {
writeUtf8(content)
}

if (isRecording) {
val goldenFile = snapshot.artifactFile(name, goldenArtifactsDirectory)
goldenFile.parentFile.mkdirs()
goldenFile.writeAtomically {
writeUtf8(content)
}
}
}

override fun close() {
if (hashes.isEmpty()) return
writer.close()
Expand Down
67 changes: 67 additions & 0 deletions paparazzi/src/main/java/app/cash/paparazzi/SnapshotVerifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import app.cash.paparazzi.internal.differs.Mssim
import app.cash.paparazzi.internal.differs.OffByTwo
import app.cash.paparazzi.internal.differs.PixelPerfect
import app.cash.paparazzi.internal.differs.Sift
import com.squareup.moshi.JsonReader
import okio.Buffer
import okio.Path.Companion.toOkioPath
import java.awt.image.BufferedImage
import java.awt.image.BufferedImage.TYPE_INT_ARGB
Expand All @@ -38,10 +40,12 @@ public class SnapshotVerifier @JvmOverloads constructor(
) : SnapshotHandler {
private val imagesDirectory: File = File(rootDirectory, "images")
private val videosDirectory: File = File(rootDirectory, "videos")
private val artifactsDirectory: File = File(rootDirectory, ARTIFACTS_DIRECTORY_NAME)

init {
imagesDirectory.mkdirs()
videosDirectory.mkdirs()
artifactsDirectory.mkdirs()
}

override fun newFrameHandler(snapshot: Snapshot, frameCount: Int, fps: Int): FrameHandler {
Expand Down Expand Up @@ -93,11 +97,74 @@ public class SnapshotVerifier @JvmOverloads constructor(
pngVerifier?.close()
}
}

override fun handleArtifact(name: String, content: String) {
val expected = snapshot.artifactFile(name, artifactsDirectory)
val failureFileBaseName = "${expected.parentFile.name.sanitizeForFilename()}-${expected.name}"
val actualFile = File(failureDir, "actual-$failureFileBaseName")
val diffFile = File(failureDir, "diff-$failureFileBaseName.txt")
if (!expected.exists()) {
actualFile.writeAtomically(content)
diffFile.writeAtomically(
buildString {
appendLine("Golden artifact not found for '$name'.")
appendLine("Expected file: ${expected.path}")
appendLine("Actual file: ${actualFile.path}")
appendLine()
appendLine("Actual:")
appendLine(content)
}
)
throw AssertionError(
"Golden artifact '$name' not found at ${expected.path}. " +
"See ${actualFile.path} and ${diffFile.path}."
)
}

val expectedContent = expected.readText()
val expectedJson = expectedContent.parseJsonSafely()
val actualJson = content.parseJsonSafely()
if (expectedJson == actualJson) return

actualFile.writeAtomically(content)
diffFile.writeAtomically(
buildString {
appendLine("Artifact mismatch for '$name'.")
appendLine("Expected file: ${expected.path}")
appendLine()
appendLine("Expected:")
appendLine(expectedContent)
appendLine()
appendLine("Actual:")
appendLine(content)
}
)

throw AssertionError(
"Artifact '$name' mismatch for ${expected.path}. " +
"See ${actualFile.path} and ${diffFile.path}."
)
}
}
}

override fun close(): Unit = Unit

private fun String.parseJsonSafely(): Any? {
return try {
JsonReader.of(Buffer().writeUtf8(this)).readJsonValue()
} catch (_: Exception) {
this
}
}

private fun File.writeAtomically(content: String) {
val tmpFile = File(parentFile, "$name.tmp")
tmpFile.writeText(content)
delete()
tmpFile.renameTo(this)
}

private companion object {
/** Directory where to write the thumbnails and deltas. */
private val failureDir: File
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package app.cash.paparazzi.accessibility

internal const val ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME = "accessibility-hierarchy.json"
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import android.view.WindowManager
import android.view.WindowManagerImpl
import android.widget.FrameLayout
import android.widget.LinearLayout
import app.cash.paparazzi.Flags
import app.cash.paparazzi.RenderExtension
import app.cash.paparazzi.internal.ComposeViewAdapter
import com.android.internal.view.OneShotPreDrawListener
Expand Down Expand Up @@ -91,8 +92,15 @@ public class AccessibilityRenderExtension : RenderExtension {
}

override fun onSnapshotRunCompleted() {
onSnapshotRunCompleted { _, _ -> }
}

override fun onSnapshotRunCompleted(onArtifact: (name: String, content: String) -> Unit) {
val hierarchyString = accessibilityElementCollector.toHierarchyString(collectedElements)
onHierarchyStringGenerated(hierarchyString)
if (System.getProperty(Flags.ACCESSIBILITY_HIERARCHY_ARTIFACTS_ENABLED)?.toBoolean() == true) {
onArtifact(ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME, hierarchyString)
}
collectedElements = emptySet()
}
}
Expand Down
128 changes: 128 additions & 0 deletions paparazzi/src/test/java/app/cash/paparazzi/HtmlReportWriterTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package app.cash.paparazzi

import app.cash.paparazzi.FileSubject.Companion.assertThat
import app.cash.paparazzi.accessibility.ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME
import app.cash.paparazzi.internal.differs.PixelPerfect
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
Expand Down Expand Up @@ -128,6 +129,133 @@ class HtmlReportWriterTest {
assertThat(File(reportRoot.root, "videos")).isEmptyDirectory()
}

@Test
fun writesAccessibilityArtifactToReportOutput() {
val htmlReportWriter = HtmlReportWriter(
runName = "run_one",
rootDirectory = reportRoot.root,
maxPercentDifference = 0.0,
differ = PixelPerfect,
snapshotRootDirectory = snapshotRoot.root
)

val snapshot = Snapshot(
name = "loading",
testName = TestName("app.cash.paparazzi", "CelebrityTest", "testSettings"),
timestamp = Instant.parse("2019-03-20T10:27:43Z").toDate()
)
val expectedReportArtifact = snapshot.artifactFile(
ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME,
File(reportRoot.root, ARTIFACTS_DIRECTORY_NAME)
)
val expectedGoldenArtifact = snapshot.artifactFile(
ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME,
File(snapshotRoot.root, ARTIFACTS_DIRECTORY_NAME)
)
val genericArtifactName = "debug-info.txt"
val genericArtifactContent = "artifact debug information"
val expectedReportGenericArtifact = snapshot.artifactFile(
genericArtifactName,
File(reportRoot.root, ARTIFACTS_DIRECTORY_NAME)
)
val expectedGoldenGenericArtifact = snapshot.artifactFile(
genericArtifactName,
File(snapshotRoot.root, ARTIFACTS_DIRECTORY_NAME)
)

htmlReportWriter.use {
val frameHandler = htmlReportWriter.newFrameHandler(
snapshot = snapshot,
frameCount = 1,
fps = -1
)
frameHandler.use {
frameHandler.handle(anyImage)
frameHandler.handleArtifact(
ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME,
"""
|[
| {
| "legendText": "First"
| }
|]
""".trimMargin()
)
frameHandler.handleArtifact(genericArtifactName, genericArtifactContent)
}
}

assertThat(expectedReportArtifact).hasContent(
"""
|[
| {
| "legendText": "First"
| }
|]
""".trimMargin()
)
assertThat(expectedGoldenArtifact).doesNotExist()
assertThat(expectedReportGenericArtifact).hasContent(genericArtifactContent)
assertThat(expectedGoldenGenericArtifact).doesNotExist()
}

@Test
fun writesAccessibilityArtifactGoldenWhenRecording() {
try {
System.setProperty("paparazzi.test.record", "true")

val htmlReportWriter = HtmlReportWriter(
runName = "run_one",
rootDirectory = reportRoot.root,
maxPercentDifference = 0.0,
differ = PixelPerfect,
snapshotRootDirectory = snapshotRoot.root
)

val snapshot = Snapshot(
name = "loading",
testName = TestName("app.cash.paparazzi", "CelebrityTest", "testSettings"),
timestamp = Instant.parse("2019-03-20T10:27:43Z").toDate()
)
val artifactContent =
"""
|[
| {
| "legendText": "First"
| }
|]
""".trimMargin()
val genericArtifactName = "debug-info.txt"
val genericArtifactContent = "artifact debug information"
val expectedGoldenArtifact = snapshot.artifactFile(
ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME,
File(snapshotRoot.root, ARTIFACTS_DIRECTORY_NAME)
)
val expectedGoldenGenericArtifact = snapshot.artifactFile(
genericArtifactName,
File(snapshotRoot.root, ARTIFACTS_DIRECTORY_NAME)
)

htmlReportWriter.use {
val frameHandler = htmlReportWriter.newFrameHandler(
snapshot = snapshot,
frameCount = 1,
fps = -1
)
frameHandler.use {
frameHandler.handle(anyImage)
frameHandler.handleArtifact(ACCESSIBILITY_HIERARCHY_ARTIFACT_NAME, artifactContent)
frameHandler.handleArtifact(genericArtifactName, genericArtifactContent)
}
}

assertThat(expectedGoldenArtifact).hasContent(artifactContent)
assertThat(expectedGoldenGenericArtifact).hasContent(genericArtifactContent)
} finally {
System.clearProperty("paparazzi.test.record")
}
}

@Test
fun imagesAlwaysOverwriteOnRecord() {
try {
Expand Down
Loading