Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LMBQ-113: During element screenshot, add 1px tolerance #269

Merged
merged 11 commits into from
Apr 19, 2023
10 changes: 1 addition & 9 deletions Lombiq.Tests.UI.Samples/Lombiq.Tests.UI.Samples.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<None Remove=".htmlvalidate.json" />
<None Remove="xunit.runner.json" />
</ItemGroup>

<ItemGroup>
<Content Include=".htmlvalidate.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
Expand All @@ -21,14 +21,6 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Tests\BasicVisualVerificationTests_VerifyBlogImage_By_ClassName[Contains]_-field-name-blog-image.png" />
<EmbeddedResource Include="Tests\BasicVisualVerificationTests_VerifyNavbar_By_ClassName[Contains]_-navbar-brand_Unix_chrome.png" />
<EmbeddedResource Include="Tests\BasicVisualVerificationTests_VerifyNavbar_By_ClassName[Contains]_-navbar-brand_Unix_msedge.png" />
<EmbeddedResource Include="Tests\BasicVisualVerificationTests_VerifyNavbar_By_ClassName[Contains]_-navbar-brand_Win32NT_chrome.png" />
<EmbeddedResource Include="Tests\BasicVisualVerificationTests_VerifyNavbar_By_ClassName[Contains]_-navbar-brand_Win32NT_msedge.png" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@ namespace Lombiq.Tests.UI.Exceptions;
public class VisualVerificationBaselineImageNotFoundException : Exception
#pragma warning restore CA1032 // Implement standard exception constructors
{
public VisualVerificationBaselineImageNotFoundException(string path)
: this(path, innerException: null)
{
}

public VisualVerificationBaselineImageNotFoundException(string path, Exception innerException)
public VisualVerificationBaselineImageNotFoundException(string path, Exception innerException = null)
: base(
$"Baseline image file not found, thus it was created automatically under the path {path}."
+ " Please set its \"Build action\" to \"Embedded resource\" if you want to deploy a self-contained"
Expand Down
49 changes: 35 additions & 14 deletions Lombiq.Tests.UI/Extensions/ScreenshotUITestContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ public static Image TakeFullPageScreenshot(this UITestContext context)
var height = images.Keys.Sum(
position =>
position.Y % viewportSize.Height == 0
? viewportSize.Height
: (position.Y + viewportSize.Height) % viewportSize.Height);
? viewportSize.Height
: (position.Y + viewportSize.Height) % viewportSize.Height);

var screenshot = new SixLabors.ImageSharp.Image<Argb32>(viewportSize.Width, height);

Expand Down Expand Up @@ -105,27 +105,48 @@ public static Image TakeElementScreenshot(this UITestContext context, IWebElemen
{
using var screenshot = context.TakeFullPageScreenshot();

var elementAbsoluteSize = new Size(
element.Location.X + element.Size.Width,
element.Location.Y + element.Size.Height);
var elementLocation = element.Location;
var elementSize = element.Size;

if (elementAbsoluteSize.Width > screenshot.Width || elementAbsoluteSize.Height > screenshot.Height)
var expectedSize = new Size(elementLocation.X + elementSize.Width, elementLocation.Y + elementSize.Height);

var widthDifference = expectedSize.Width - screenshot.Width;
var heightDifference = expectedSize.Height - screenshot.Height;

if (widthDifference > 0 || heightDifference > 0)
{
throw new InvalidOperationException(
"The captured screenshot size is smaller then the size required by the selected element. This can occur"
+ " if there was an unsuccessful scrolling operation while capturing page parts."
+ $"Captured size: {screenshot.Width.ToTechnicalString()} x {screenshot.Height.ToTechnicalString()}. "
+ $"Required size: {elementAbsoluteSize.Width.ToTechnicalString()} x "
+ $"{elementAbsoluteSize.Height.ToTechnicalString()}.");
// A difference of 1px can occur when both element.Location and element.Size were rounded to the next
// integer from exactly 0.5px, e.g. 212.5px + 287.5px = 500px in the browser, but due to both Size and Point
// using int for their coordinates, this will become 213px + 288px = 501px, which will be caught as an error
// here. In that case, we keep the elementSize as is, but reduce the Location coordinate by 1 so that
// cropping the full page screenshot to the desired region will not fail due to too large dimensions.
if (widthDifference <= 1 && heightDifference <= 1)
{
elementLocation.X -= widthDifference;
elementLocation.Y -= heightDifference;
}
else
{
throw new InvalidOperationException(
"The captured screenshot size is smaller then the size required by the selected element. This can"
+ " occur if there was an unsuccessful scrolling operation while capturing page parts."
+ $" Captured size: {AsDimensions(screenshot)}. Expected size: {AsDimensions(expectedSize)}.");
}
}

var bounds = new Rectangle(element.Location.X, element.Location.Y, element.Size.Width, element.Size.Height);
return screenshot.Clone(ctx => ctx.Crop(bounds));
var cropRectangle = new Rectangle(elementLocation.X, elementLocation.Y, elementSize.Width, elementSize.Height);
return screenshot.Clone(image => image.Crop(cropRectangle));
}

/// <summary>
/// Takes a screenshot of an element region only.
/// </summary>
public static Image TakeElementScreenshot(this UITestContext context, By elementSelector) =>
context.TakeElementScreenshot(context.Get(elementSelector));

private static string AsDimensions(IImageInfo image) =>
$"{image.Width.ToTechnicalString()} x {image.Height.ToTechnicalString()}";

private static string AsDimensions(Size size) =>
$"{size.Width.ToTechnicalString()} x {size.Height.ToTechnicalString()}";
DemeSzabolcs marked this conversation as resolved.
Show resolved Hide resolved
}
77 changes: 39 additions & 38 deletions Lombiq.Tests.UI/Services/OrchardCoreUITestExecutorConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,41 @@ public enum Browser

public class OrchardCoreUITestExecutorConfiguration
{
public static readonly Func<IWebApplicationInstance, Task> AssertAppLogsAreEmptyAsync = app =>
app.LogsShouldBeEmptyAsync();

public static readonly Func<IWebApplicationInstance, Task> AssertAppLogsCanContainWarningsAsync =
app => app.LogsShouldBeEmptyAsync(canContainWarnings: true);

public static readonly Action<IEnumerable<LogEntry>> AssertBrowserLogIsEmpty =
logEntries => logEntries.ShouldNotContain(
logEntry => IsValidBrowserLogEntry(logEntry),
logEntries.Where(IsValidBrowserLogEntry).ToFormattedString());

public static readonly Func<LogEntry, bool> IsValidBrowserLogEntry =
logEntry =>
logEntry.Level >= LogLevel.Warning &&
// HTML imports are somehow used by Selenium or something but this deprecation notice is always there for
// every page.
!logEntry.Message.ContainsOrdinalIgnoreCase("HTML Imports is deprecated") &&
// The 404 is because of how browsers automatically request /favicon.ico even if a favicon is declared to be
// under a different URL.
!logEntry.IsNotFoundLogEntry("/favicon.ico");

/// <summary>
/// Gets the global events available during UI test execution.
/// </summary>
public UITestExecutionEvents Events { get; } = new();

/// <summary>
/// Gets a dictionary storing some custom configuration data.
/// </summary>
[SuppressMessage(
"Design",
"MA0016:Prefer return collection abstraction instead of implementation",
Justification = "Deliberately modifiable by consumer code.")]
public Dictionary<string, object> CustomConfiguration { get; } = new();

public BrowserConfiguration BrowserConfiguration { get; set; } = new();
public TimeoutConfiguration TimeoutConfiguration { get; set; } = TimeoutConfiguration.Default;
public AtataConfiguration AtataConfiguration { get; set; } = new();
Expand Down Expand Up @@ -53,10 +88,10 @@ public class OrchardCoreUITestExecutorConfiguration
/// </para>
/// </remarks>
public int MaxParallelTests { get; set; } =
TestConfigurationManager.GetIntConfiguration(
$"{nameof(OrchardCoreUITestExecutorConfiguration)}:{nameof(MaxParallelTests)}") is not { } intValue || intValue == 0
? Environment.ProcessorCount
: intValue;
TestConfigurationManager.GetIntConfiguration(
$"{nameof(OrchardCoreUITestExecutorConfiguration)}:{nameof(MaxParallelTests)}") is { } intValue and > 0
? intValue
: Environment.ProcessorCount;

public Func<IWebApplicationInstance, Task> AssertAppLogsAsync { get; set; } = AssertAppLogsCanContainWarningsAsync;
public Action<IEnumerable<LogEntry>> AssertBrowserLog { get; set; } = AssertBrowserLogIsEmpty;
Expand Down Expand Up @@ -136,20 +171,6 @@ public class OrchardCoreUITestExecutorConfiguration
/// </summary>
public ShortcutsConfiguration ShortcutsConfiguration { get; set; } = new();

/// <summary>
/// Gets the global events available during UI test execution.
/// </summary>
public UITestExecutionEvents Events { get; } = new();

/// <summary>
/// Gets a dictionary storing some custom configuration data.
/// </summary>
[SuppressMessage(
"Design",
"MA0016:Prefer return collection abstraction instead of implementation",
Justification = "Deliberately modifiable by consumer code.")]
public Dictionary<string, object> CustomConfiguration { get; } = new();

public async Task AssertAppLogsMaybeAsync(IWebApplicationInstance instance, Action<string> log)
{
if (instance == null || AssertAppLogsAsync == null) return;
Expand Down Expand Up @@ -183,24 +204,4 @@ public void AssertBrowserLogMaybe(IList<LogEntry> browserLogs, Action<string> lo
throw;
}
}

public static readonly Func<IWebApplicationInstance, Task> AssertAppLogsAreEmptyAsync = app => app.LogsShouldBeEmptyAsync();

public static readonly Func<IWebApplicationInstance, Task> AssertAppLogsCanContainWarningsAsync =
app => app.LogsShouldBeEmptyAsync(canContainWarnings: true);

public static readonly Action<IEnumerable<LogEntry>> AssertBrowserLogIsEmpty =
// HTML imports are somehow used by Selenium or something but this deprecation notice is always there for every
// page.
logEntries => logEntries.ShouldNotContain(
logEntry => IsValidBrowserLogEntry(logEntry),
logEntries.Where(IsValidBrowserLogEntry).ToFormattedString());

public static readonly Func<LogEntry, bool> IsValidBrowserLogEntry =
logEntry =>
logEntry.Level >= LogLevel.Warning &&
!logEntry.Message.ContainsOrdinalIgnoreCase("HTML Imports is deprecated") &&
// The 404 is because of how browsers automatically request /favicon.ico even if a favicon is declared to be
// under a different URL.
!logEntry.IsNotFoundLogEntry("/favicon.ico");
}