@@ -17,7 +17,10 @@ package app.cash.redwood.dom.testing
17
17
18
18
import kotlin.coroutines.resume
19
19
import kotlin.coroutines.resumeWithException
20
+ import kotlin.math.abs
20
21
import kotlin.math.ceil
22
+ import kotlin.math.max
23
+ import kotlin.math.min
21
24
import kotlinx.browser.document
22
25
import kotlinx.coroutines.await
23
26
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -69,6 +72,7 @@ public class DomSnapshotter @PublishedApi internal constructor(
69
72
this .canvasWidth = this .width
70
73
this .canvasHeight = this .height
71
74
this .pixelRatio = frame.pixelRatio
75
+ this .backgroundColor = " transparent"
72
76
},
73
77
).await()
74
78
} finally {
@@ -83,57 +87,145 @@ public class DomSnapshotter @PublishedApi internal constructor(
83
87
val fileName = " $path /$name .png"
84
88
85
89
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)"
88
96
}
89
97
} ? : snapshotStore.put(fileName, image)
90
98
}
91
99
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)
97
103
98
104
try {
99
105
val img1 = loadImage(url1)
100
106
val img2 = loadImage(url2)
101
107
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)
105
115
116
+ // Create canvas for composite image (expected + delta + actual)
106
117
val canvas = document.createElement(" canvas" ) as HTMLCanvasElement
107
118
val ctx = canvas.getContext(" 2d" ) as CanvasRenderingContext2D
119
+ canvas.width = maxWidth * 3 // Three sections of maxWidth
120
+ canvas.height = maxHeight
108
121
109
- canvas.width = img1.width
110
- canvas.height = img1.height
111
-
112
- // Get data for first image
122
+ // Draw expected image on the left
113
123
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()
127
177
}
128
178
}
129
179
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
+ )
131
216
} finally {
132
217
URL .revokeObjectURL(url1)
133
218
URL .revokeObjectURL(url2)
134
219
}
135
220
}
136
221
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
+
137
229
private suspend fun loadImage (url : String ): HTMLImageElement =
138
230
suspendCancellableCoroutine { continuation ->
139
231
val img = document.createElement(" img" ) as HTMLImageElement
0 commit comments