Skip to content

Commit

Permalink
[Feature] Find elements by image (#234) +semver: feature
Browse files Browse the repository at this point in the history
* [Feature] Find elements by image +semver: feature
- Implement ByImage locator
- elements location strategy: 
 - Finding the closest element to matching point instead of the topmost element/ all elements on point
- Support finding multiple elements (multiple image matches)
- Support relative search (e.g. from element)
- Add js script to GetElementsFromPoint
- Add locator test
- Implement screenshot scaling in case when devicePixelRatio!=1 (like on modern Mac with Retina display)
- Add possibility to change the threshold for one ByImage locator

* Correct DevToolsHandling method declaration
  • Loading branch information
mialeska authored Feb 15, 2024
1 parent b99643c commit cd1abcc
Show file tree
Hide file tree
Showing 13 changed files with 334 additions and 11 deletions.
11 changes: 10 additions & 1 deletion Aquality.Selenium/src/Aquality.Selenium/Aquality.Selenium.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

<ItemGroup>
<None Remove="Resources\JavaScripts\ExpandShadowRoot.js" />
<None Remove="Resources\JavaScripts\GetDevicePixelRatio.js" />
<None Remove="Resources\JavaScripts\GetElementsFromPoint.js" />
<None Remove="Resources\JavaScripts\GetElementCssSelector.js" />
<None Remove="Resources\JavaScripts\SetAttribute.js" />
<None Remove="Resources\Localization\be.json" />
Expand All @@ -47,6 +49,8 @@
<EmbeddedResource Include="Resources\JavaScripts\GetCheckBoxState.js" />
<EmbeddedResource Include="Resources\JavaScripts\GetComboBoxSelectedText.js" />
<EmbeddedResource Include="Resources\JavaScripts\GetComboBoxTexts.js" />
<EmbeddedResource Include="Resources\JavaScripts\GetDevicePixelRatio.js" />
<EmbeddedResource Include="Resources\JavaScripts\GetElementsFromPoint.js" />
<EmbeddedResource Include="Resources\JavaScripts\GetElementByXPath.js" />
<EmbeddedResource Include="Resources\JavaScripts\ExpandShadowRoot.js" />
<EmbeddedResource Include="Resources\JavaScripts\GetElementText.js" />
Expand Down Expand Up @@ -78,8 +82,13 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Aquality.Selenium.Core" Version="3.0.5" />
<PackageReference Include="Aquality.Selenium.Core" Version="3.0.6" />
<PackageReference Include="WebDriverManager" Version="2.17.1" />
<PackageReference Include="OpenCvSharp4" Version="4.9.0.20240103" />
<PackageReference Include="OpenCvSharp4.runtime.linux-arm" Version="4.9.0.20240103" />
<PackageReference Include="OpenCvSharp4.runtime.osx.10.15-x64" Version="4.6.0.20230105" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.9.0.20240103" />
<PackageReference Include="OpenCvSharp4_.runtime.ubuntu.20.04-x64" Version="4.9.0.20240103" />
</ItemGroup>

</Project>
60 changes: 59 additions & 1 deletion Aquality.Selenium/src/Aquality.Selenium/Aquality.Selenium.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public void CloseDevToolsSession()

/// <summary>
/// Creates a session to communicate with a browser using the Chromium Developer Tools debugging protocol.
/// Calls overload <see cref="GetDevToolsSession(int)"/>, where parameter protocolVersion
/// Calls overload <see cref="GetDevToolsSession(DevToolsOptions)"/>, where parameter protocolVersion
/// defaults to autodetect the protocol version for Chromium, V85 for Firefox.
/// </summary>
/// <returns>The active session to use to communicate with the Chromium Developer Tools debugging protocol.</returns>
Expand All @@ -79,6 +79,7 @@ public DevToolsSession GetDevToolsSession()
/// <param name="protocolVersion">The version of the Chromium Developer Tools protocol to use.
/// Defaults to autodetect the protocol version for <see cref="ChromiumDriver"/>, V85 for FirefoxDriver.</param>
/// <returns>The active session to use to communicate with the Chromium Developer Tools debugging protocol.</returns>
[Obsolete("Use GetDevToolsSession(DevToolsOptions options)")]
public DevToolsSession GetDevToolsSession(int protocolVersion)
{
Logger.Info("loc.browser.devtools.session.get", protocolVersion);
Expand All @@ -87,6 +88,20 @@ public DevToolsSession GetDevToolsSession(int protocolVersion)
return session;
}

/// <summary>
/// Creates a session to communicate with a browser using the Chromium Developer Tools debugging protocol.
/// </summary>
/// <param name="options"> The options for the DevToolsSession to use.
/// Defaults to autodetect the protocol version for <see cref="ChromiumDriver"/>, V85 for FirefoxDriver.</param>
/// <returns>The active session to use to communicate with the Chromium Developer Tools debugging protocol.</returns>
public DevToolsSession GetDevToolsSession(DevToolsOptions options)
{
Logger.Info("loc.browser.devtools.session.get", options.ProtocolVersion?.ToString() ?? "default");
var session = devToolsProvider.GetDevToolsSession(options);
wasDevToolsSessionClosed = false;
return session;
}

/// <summary>
/// Executes a custom Chromium Dev Tools Protocol Command.
/// Note: works only if current driver is instance of <see cref="ChromiumDriver"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ public enum JavaScript
GetCheckBoxState,
GetComboBoxSelectedText,
GetComboBoxTexts,
GetDevicePixelRatio,
GetElementByXPath,
GetElementsFromPoint,
GetElementText,
GetElementXPath,
GetElementCssSelector,
Expand Down
163 changes: 163 additions & 0 deletions Aquality.Selenium/src/Aquality.Selenium/Elements/Interfaces/ByImage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using Aquality.Selenium.Browsers;
using Aquality.Selenium.Core.Configurations;
using OpenCvSharp;
using OpenQA.Selenium;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;

namespace Aquality.Selenium.Elements.Interfaces
{
/// <summary>
/// Locator to search elements by image.
/// Takes screenshot and finds match using openCV.
/// Then finds elements by coordinates using JavaScript.
/// </summary>
public class ByImage : By, IDisposable
{
private readonly Mat template;
private readonly string description;

/// <summary>
/// Constructor accepting image file.
/// </summary>
/// <param name="file">Image file to locate element by.</param>
public ByImage(FileInfo file)
{
description= file.Name;
template = new Mat(file.FullName, ImreadModes.Unchanged);
}

/// <summary>
/// Constructor accepting image bytes.
/// </summary>
/// <param name="bytes">Image bytes to locate element by.</param>
public ByImage(byte[] bytes)
{
description = $"bytes[%d]";
template = Mat.ImDecode(bytes, ImreadModes.Unchanged);
}

/// <summary>
/// Threshold of image similarity.
/// Should be a float between 0 and 1, where 1 means 100% match, and 0.5 means 50% match.
/// </summary>
public virtual float Threshold { get; set; } = 1 - AqualityServices.Get<IVisualizationConfiguration>().DefaultThreshold;

public override IWebElement FindElement(ISearchContext context)
{
return FindElements(context)?.FirstOrDefault()
?? throw new NoSuchElementException($"Cannot locate an element using {ToString()}");
}

public override ReadOnlyCollection<IWebElement> FindElements(ISearchContext context)
{
var source = GetScreenshot(context);
var result = new Mat();

Cv2.MatchTemplate(source, template, result, TemplateMatchModes.CCoeffNormed);

Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var matchLocation);
var matchCounter = Math.Abs((result.Width - template.Width + 1) * (result.Height - template.Height + 1));
var matchLocations = new List<Point>();
while (matchCounter > 0 && maxVal >= Threshold)
{
matchCounter--;
matchLocations.Add(matchLocation);
Cv2.Rectangle(result, new Rect(matchLocation.X, matchLocation.Y, template.Width, template.Height), Scalar.Black, -1);
Cv2.MinMaxLoc(result, out _, out maxVal, out _, out matchLocation);
}

return matchLocations.Select(match => GetElementOnPoint(match, context)).ToList().AsReadOnly();
}

/// <summary>
/// Gets a single element on point (find by center coordinates, then select closest to matchLocation).
/// </summary>
/// <param name="matchLocation">Location of the upper-left point of the element.</param>
/// <param name="context">Search context.
/// If the searchContext is Locatable (like WebElement), will adjust coordinates to be absolute coordinates.</param>
/// <returns>The closest found element.</returns>
protected virtual IWebElement GetElementOnPoint(Point matchLocation, ISearchContext context)
{
if (context is ILocatable locatable)
{
var point = locatable.Coordinates.LocationInDom;
matchLocation = matchLocation.Add(new Point(point.X, point.Y));
}
var centerLocation = matchLocation.Add(new Point(template.Width / 2, template.Height / 2));

var elements = AqualityServices.Browser.ExecuteScript<IList<IWebElement>>(JavaScript.GetElementsFromPoint, centerLocation.X, centerLocation.Y)
.OrderBy(element => DistanceToPoint(matchLocation, element));
return elements.First();
}

/// <summary>
/// Calculates distance from element to matching point.
/// </summary>
/// <param name="matchLocation">Matching point.</param>
/// <param name="element">Target element.</param>
/// <returns>Distance in pixels.</returns>
protected virtual double DistanceToPoint(Point matchLocation, IWebElement element)
{
var elementLocation = element.Location;
return Math.Sqrt(Math.Pow(matchLocation.X - elementLocation.X, 2) + Math.Pow(matchLocation.Y - elementLocation.Y, 2));
}

/// <summary>
/// Takes screenshot from searchContext if supported, or from browser.
/// Performs screenshot scaling if devicePixelRatio != 1.
/// </summary>
/// <param name="context">Search context for element location.</param>
/// <returns>Captured screenshot as Mat object.</returns>
protected virtual Mat GetScreenshot(ISearchContext context)
{
var screenshotBytes = context is ITakesScreenshot
? (context as ITakesScreenshot).GetScreenshot().AsByteArray
: AqualityServices.Browser.GetScreenshot();
var isBrowserScreenshot = context is IWebDriver || !(context is ITakesScreenshot);
var source = Mat.ImDecode(screenshotBytes, ImreadModes.Unchanged);
var devicePixelRatio = AqualityServices.Browser.ExecuteScript<long>(JavaScript.GetDevicePixelRatio);
if (devicePixelRatio != 1 && isBrowserScreenshot)
{
var scaledWidth = (int)(source.Width / devicePixelRatio);
var scaledHeight = (int)(source.Height / devicePixelRatio);
Cv2.Resize(source, source, new Size(scaledWidth, scaledHeight), interpolation: InterpolationFlags.Area);
}

return source;
}

public override string ToString()
{
return $"ByImage: {description}, size: {template.Size()}";
}

public override bool Equals(object obj)
{
ByImage by = obj as ByImage;
return by != null && template.ToString().Equals(by.template?.ToString());
}

public override int GetHashCode()
{
return template.GetHashCode();
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
if (disposing)
{
template.Dispose();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
return window.devicePixelRatio;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
return document.elementsFromPoint(arguments[0], arguments[1]);
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,17 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Aquality.Selenium\Aquality.Selenium.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="Resources\BrokenImage.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Resources\settings.azure.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Aquality.Selenium.Browsers;
using Aquality.Selenium.Elements.Interfaces;
using Aquality.Selenium.Tests.Integration.TestApp.TheInternet.Forms;
using NUnit.Framework;
using OpenQA.Selenium;

namespace Aquality.Selenium.Tests.Integration
{
internal class ImageLocatorTests : UITest
{
private readonly BrokenImagesForm form = new BrokenImagesForm();

[Test]
public void Should_BePossibleTo_FindByImage()
{
new CheckBoxesForm().Open();
Assert.That(form.LabelByImage.State.IsDisplayed, Is.False, "Should be impossible to find element on page by image when it is absent");
form.Open();
Assert.That(form.LabelByImage.State.IsDisplayed, "Should be possible to find element on page by image");
Assert.That(form.LabelByImage.GetElement().TagName, Is.EqualTo("img"), "Correct element must be found");

var childLabels = form.ChildLabelsByImage;
var docLabels = form.LabelsByImage;
Assert.That(docLabels.Count, Is.GreaterThan(1), "List of elements should be possible to find by image");
Assert.That(docLabels.Count, Is.EqualTo(childLabels.Count), "Should be possible to find child elements by image with the same count");

var documentByTag = AqualityServices.Get<IElementFactory>().GetLabel(By.TagName("body"), "document by tag");
var fullThreshold = 1;
var documentByImage = AqualityServices.Get<IElementFactory>().GetLabel(new ByImage(documentByTag.GetElement().GetScreenshot().AsByteArray) { Threshold = fullThreshold },
"body screen");
Assert.That(documentByImage.State.IsDisplayed, "Should be possible to find element by document screenshot");
Assert.That((documentByImage.Locator as ByImage)?.Threshold, Is.EqualTo(fullThreshold), "Should be possible to get ByImage threshold");
Assert.That(documentByImage.GetElement().TagName, Is.EqualTo("body"), "Correct element must be found");
}
}
}
Loading

0 comments on commit cd1abcc

Please sign in to comment.