Skip to content

Commit 769e0f1

Browse files
committed
Generate diff image for web snapshots
1 parent 3f0643b commit 769e0f1

File tree

6 files changed

+143
-28
lines changed

6 files changed

+143
-28
lines changed

.github/workflows/build.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ jobs:
4343
runs-on: macos-latest
4444
steps:
4545
- uses: actions/checkout@v5
46+
with:
47+
lfs: true
4648
- uses: actions/setup-java@v5
4749
with:
4850
distribution: 'zulu'
@@ -153,6 +155,15 @@ jobs:
153155

154156
- run: xcodebuild -project redwood-widget-uiview-test/RedwoodWidgetUIViewTests.xcodeproj -scheme RedwoodWidgetUIViewTests -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.4' test
155157

158+
- run: ./gradlew redwood-layout-dom:jsBrowserTest
159+
160+
- uses: actions/upload-artifact@v4
161+
if: ${{ always() }}
162+
with:
163+
name: screenshot-diff-images
164+
path: '**//snapshots/**/*diff.png'
165+
retention-days: 1
166+
156167
- uses: actions/upload-artifact@v4
157168
if: ${{ always() }}
158169
with:

redwood-dom-testing/src/commonMain/kotlin/app/cash/redwood/dom/testing/DomSnapshotter.kt

Lines changed: 120 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ package app.cash.redwood.dom.testing
1717

1818
import kotlin.coroutines.resume
1919
import kotlin.coroutines.resumeWithException
20+
import kotlin.math.abs
2021
import kotlin.math.ceil
22+
import kotlin.math.max
23+
import kotlin.math.min
2124
import kotlinx.browser.document
2225
import kotlinx.coroutines.await
2326
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -69,6 +72,7 @@ public class DomSnapshotter @PublishedApi internal constructor(
6972
this.canvasWidth = this.width
7073
this.canvasHeight = this.height
7174
this.pixelRatio = frame.pixelRatio
75+
this.backgroundColor = "transparent"
7276
},
7377
).await()
7478
} finally {
@@ -83,57 +87,145 @@ public class DomSnapshotter @PublishedApi internal constructor(
8387
val fileName = "$path/$name.png"
8488

8589
snapshotStore.getBlob(fileName)?.let { existing ->
86-
check(existing.contentEquals(image)) {
87-
"Current snapshot does not match the existing file $fileName"
90+
val diffResult = compareImages(existing, image)
91+
check(!diffResult.isDifferent) {
92+
// Save the delta image with a .diff.png extension
93+
snapshotStore.put("$path/$name.diff.png", diffResult.deltaImage!!)
94+
"Current snapshot does not match the existing file $fileName " +
95+
"(${diffResult.percentDifference}% different, ${diffResult.numDifferentPixels} pixels)"
8896
}
8997
} ?: snapshotStore.put(fileName, image)
9098
}
9199

92-
private suspend fun Blob.contentEquals(other: Blob): Boolean {
93-
if (this.size != other.size) return false
94-
95-
val url1 = URL.createObjectURL(this)
96-
val url2 = URL.createObjectURL(other)
100+
private suspend fun compareImages(expected: Blob, actual: Blob): DiffResult {
101+
val url1 = URL.createObjectURL(expected)
102+
val url2 = URL.createObjectURL(actual)
97103

98104
try {
99105
val img1 = loadImage(url1)
100106
val img2 = loadImage(url2)
101107

102-
if (img1.width != img2.width || img1.height != img2.height) {
103-
return false
104-
}
108+
val expectedWidth = img1.width
109+
val expectedHeight = img1.height
110+
val actualWidth = img2.width
111+
val actualHeight = img2.height
112+
113+
val maxWidth = max(expectedWidth, actualWidth)
114+
val maxHeight = max(expectedHeight, actualHeight)
105115

116+
// Create canvas for composite image (expected + delta + actual)
106117
val canvas = document.createElement("canvas") as HTMLCanvasElement
107118
val ctx = canvas.getContext("2d") as CanvasRenderingContext2D
119+
canvas.width = maxWidth * 3 // Three sections of maxWidth
120+
canvas.height = maxHeight
108121

109-
canvas.width = img1.width
110-
canvas.height = img1.height
111-
112-
// Get data for first image
122+
// Draw expected image on the left
113123
ctx.drawImage(img1, 0.0, 0.0)
114-
val data1 = ctx.getImageData(0.0, 0.0, canvas.width.toDouble(), canvas.height.toDouble())
115-
116-
// Get data for second image
117-
ctx.clearRect(0.0, 0.0, canvas.width.toDouble(), canvas.height.toDouble())
118-
ctx.drawImage(img2, 0.0, 0.0)
119-
val data2 = ctx.getImageData(0.0, 0.0, canvas.width.toDouble(), canvas.height.toDouble())
120-
121-
// Compare pixel by pixel
122-
val pixels1 = data1.data
123-
val pixels2 = data2.data
124-
for (i in 0 until pixels1.length) {
125-
if (pixels1[i] != pixels2[i]) {
126-
return false
124+
val expectedData = ctx.getImageData(0.0, 0.0, maxWidth.toDouble(), maxHeight.toDouble())
125+
126+
// Draw actual image on the right
127+
ctx.drawImage(img2, maxWidth * 2.0, 0.0)
128+
val actualData = ctx.getImageData(maxWidth * 2.0, 0.0, maxWidth.toDouble(), maxHeight.toDouble())
129+
130+
// Create delta image data
131+
val deltaData = ctx.createImageData(maxWidth.toDouble(), maxHeight.toDouble())
132+
val deltaArray = deltaData.data.asDynamic()
133+
134+
var delta: Long = 0
135+
var differentPixels: Long = 0
136+
137+
// Compare pixels
138+
for (y in 0 until maxHeight) {
139+
for (x in 0 until maxWidth) {
140+
val i = (y * maxWidth + x) * 4
141+
142+
val expectedR = expectedData.data[i].toInt()
143+
val expectedG = expectedData.data[i + 1].toInt()
144+
val expectedB = expectedData.data[i + 2].toInt()
145+
val expectedA = expectedData.data[i + 3].toInt()
146+
147+
val actualR = actualData.data[i].toInt()
148+
val actualG = actualData.data[i + 1].toInt()
149+
val actualB = actualData.data[i + 2].toInt()
150+
val actualA = actualData.data[i + 3].toInt()
151+
152+
// Calculate differences
153+
val deltaR = actualR - expectedR
154+
val deltaG = actualG - expectedG
155+
val deltaB = actualB - expectedB
156+
157+
// If pixels are identical, make it transparent
158+
if (deltaR == 0 && deltaG == 0 && deltaB == 0 && expectedA == actualA) {
159+
deltaArray[i] = expectedR
160+
deltaArray[i + 1] = expectedG
161+
deltaArray[i + 2] = expectedB
162+
deltaArray[i + 3] = min(expectedA, 32)
163+
continue
164+
}
165+
166+
differentPixels++
167+
168+
// Visualize differences with red pixel
169+
deltaArray[i] = 255
170+
deltaArray[i + 1] = 0
171+
deltaArray[i + 2] = 0
172+
deltaArray[i + 3] = 255
173+
174+
delta += abs(deltaR).toLong()
175+
delta += abs(deltaG).toLong()
176+
delta += abs(deltaB).toLong()
127177
}
128178
}
129179

130-
return true
180+
if (differentPixels == 0L) {
181+
return DiffResult(isDifferent = false)
182+
}
183+
184+
// Draw delta image in the middle
185+
ctx.putImageData(deltaData, maxWidth.toDouble(), 0.0)
186+
187+
// Convert canvas to blob
188+
val deltaBlob = suspendCancellableCoroutine<Blob> { continuation ->
189+
canvas.toBlob(
190+
{ blob ->
191+
if (blob != null) {
192+
continuation.resume(blob)
193+
} else {
194+
continuation.resumeWithException(Exception("Failed to create delta image blob"))
195+
}
196+
},
197+
"image/png",
198+
)
199+
}
200+
201+
// Calculate percentage difference
202+
val total = maxHeight.toLong() * maxWidth.toLong() * 3L * 256L
203+
var percentDifference = (delta * 100 / total.toDouble()).toFloat()
204+
205+
// Fallback to pixel difference if color delta is 0 but pixels are different
206+
if (differentPixels > 0 && percentDifference == 0f) {
207+
percentDifference = (differentPixels * 100 / (maxWidth * maxHeight).toDouble()).toFloat()
208+
}
209+
210+
return DiffResult(
211+
isDifferent = percentDifference > 0f,
212+
deltaImage = deltaBlob,
213+
percentDifference = percentDifference,
214+
numDifferentPixels = differentPixels,
215+
)
131216
} finally {
132217
URL.revokeObjectURL(url1)
133218
URL.revokeObjectURL(url2)
134219
}
135220
}
136221

222+
private data class DiffResult(
223+
val isDifferent: Boolean,
224+
val deltaImage: Blob? = null,
225+
val percentDifference: Float = 0f,
226+
val numDifferentPixels: Long = 0,
227+
)
228+
137229
private suspend fun loadImage(url: String): HTMLImageElement =
138230
suspendCancellableCoroutine { continuation ->
139231
val img = document.createElement("img") as HTMLImageElement
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)