11using System . Reflection ;
2+ using System . Text . RegularExpressions ;
23using ImageMagick ;
34using NUnit . Framework ;
5+ using NUnit . Framework . Constraints ;
46using UITest . Appium ;
57using UITest . Appium . NUnit ;
68using UITest . Core ;
@@ -123,15 +125,16 @@ public void VerifyScreenshotOrSetException(
123125 string ? name = null ,
124126 TimeSpan ? retryDelay = null ,
125127 int cropTop = 0 ,
126- int cropBottom = 0
128+ int cropBottom = 0 ,
129+ double tolerance = 0.0
127130#if MACUITEST || WINTEST
128131 , bool includeTitleBar = false
129132#endif
130133 )
131134 {
132135 try
133136 {
134- VerifyScreenshot ( name , retryDelay , cropTop , cropBottom
137+ VerifyScreenshot ( name , retryDelay , cropTop , cropBottom , tolerance
135138#if MACUITEST || WINTEST
136139 , includeTitleBar
137140#endif
@@ -143,11 +146,42 @@ public void VerifyScreenshotOrSetException(
143146 }
144147 }
145148
149+ /// <summary>
150+ /// Verifies a screenshot by comparing it against a baseline image and throws an exception if verification fails.
151+ /// </summary>
152+ /// <param name="name">Optional name for the screenshot. If not provided, a default name will be used.</param>
153+ /// <param name="retryDelay">Optional delay between retry attempts when verification fails.</param>
154+ /// <param name="cropTop">Number of pixels to crop from the top of the screenshot.</param>
155+ /// <param name="cropBottom">Number of pixels to crop from the bottom of the screenshot.</param>
156+ /// <param name="tolerance">Tolerance level for image comparison as a percentage from 0 to 100.</param>
157+ #if MACUITEST || WINTEST
158+ /// <param name="includeTitleBar">Whether to include the title bar in the screenshot comparison.</param>
159+ #endif
160+ /// <remarks>
161+ /// This method immediately throws an exception if the screenshot verification fails.
162+ /// For batch verification of multiple screenshots, consider using <see cref="VerifyScreenshotOrSetException"/> instead.
163+ /// </remarks>
164+ /// <example>
165+ /// <code>
166+ /// // Exact match (no tolerance)
167+ /// VerifyScreenshot("LoginScreen");
168+ ///
169+ /// // Allow 2% difference for dynamic content
170+ /// VerifyScreenshot("DashboardWithTimestamp", tolerance: 2.0);
171+ ///
172+ /// // Allow 5% difference for animations or slight rendering variations
173+ /// VerifyScreenshot("ButtonHoverState", tolerance: 5.0);
174+ ///
175+ /// // Combined with cropping and tolerance
176+ /// VerifyScreenshot("HeaderSection", cropTop: 50, cropBottom: 100, tolerance: 3.0);
177+ /// </code>
178+ /// </example>
146179 public void VerifyScreenshot (
147180 string ? name = null ,
148181 TimeSpan ? retryDelay = null ,
149182 int cropTop = 0 ,
150- int cropBottom = 0
183+ int cropBottom = 0 ,
184+ double tolerance = 0.0 // Add tolerance parameter (0.05 = 5%)
151185#if MACUITEST || WINTEST
152186 , bool includeTitleBar = false
153187#endif
@@ -291,8 +325,69 @@ but both can happen.
291325 actualImage = imageEditor . GetUpdatedImage ( ) ;
292326 }
293327
294- _visualRegressionTester . VerifyMatchesSnapshot ( name ! , actualImage , environmentName : environmentName , testContext : _visualTestContext ) ;
328+ // Apply tolerance if specified
329+ if ( tolerance > 0 )
330+ {
331+ VerifyWithTolerance ( name ! , actualImage , environmentName , tolerance ) ;
332+ }
333+ else
334+ {
335+ _visualRegressionTester . VerifyMatchesSnapshot ( name ! , actualImage , environmentName : environmentName , testContext : _visualTestContext ) ;
336+ }
337+ }
338+ }
339+
340+ void VerifyWithTolerance ( string name , ImageSnapshot actualImage , string environmentName , double tolerance )
341+ {
342+ if ( tolerance > 15 )
343+ {
344+ throw new ArgumentException ( $ "Tolerance { tolerance } % exceeds the acceptable limit. Please review whether this requires a different test or if it is a bug.") ;
295345 }
346+
347+ try
348+ {
349+ _visualRegressionTester . VerifyMatchesSnapshot ( name , actualImage , environmentName : environmentName , testContext : _visualTestContext ) ;
350+ }
351+ catch ( Exception ex ) when ( IsVisualDifferenceException ( ex ) )
352+ {
353+ var difference = ExtractDifferencePercentage ( ex ) ;
354+ if ( difference <= tolerance )
355+ {
356+ // Log warning but pass test
357+ TestContext . WriteLine ( $ "Visual difference { difference } % within tolerance { tolerance } % for '{ name } ' on { environmentName } ") ;
358+ return ;
359+ }
360+ throw ; // Re-throw if exceeds tolerance
361+ }
362+ }
363+
364+ bool IsVisualDifferenceException ( Exception ex )
365+ {
366+ // Check if this is a visual regression failure
367+ return ex . GetType ( ) . Name . Contains ( "Assert" , StringComparison . Ordinal ) ||
368+ ex . Message . Contains ( "Snapshot different" , StringComparison . Ordinal ) ||
369+ ex . Message . Contains ( "baseline" , StringComparison . Ordinal ) ||
370+ ex . Message . Contains ( "different" , StringComparison . Ordinal ) ;
371+ }
372+
373+ double ExtractDifferencePercentage ( Exception ex )
374+ {
375+ var message = ex . Message ;
376+
377+ // Extract percentage from pattern: "X,XX% difference"
378+ var match = Regex . Match ( message , @"(\d+,\d+)%\s*difference" , RegexOptions . IgnoreCase ) ;
379+ if ( match . Success )
380+ {
381+ var percentageString = match . Groups [ 1 ] . Value . Replace ( ',' , '.' ) ;
382+ if ( double . TryParse ( percentageString , System . Globalization . NumberStyles . Float ,
383+ System . Globalization . CultureInfo . InvariantCulture , out var percentage ) )
384+ {
385+ return percentage ;
386+ }
387+ }
388+
389+ // If can't extract specific percentage, throw an exception to indicate failure
390+ throw new InvalidOperationException ( "Unable to extract difference percentage from exception message." ) ;
296391 }
297392
298393 protected void VerifyInternetConnectivity ( )
0 commit comments