Skip to content

Commit cd1abcc

Browse files
authored
[Feature] Find elements by image (#234) +semver: feature
* [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
1 parent b99643c commit cd1abcc

File tree

13 files changed

+334
-11
lines changed

13 files changed

+334
-11
lines changed

Aquality.Selenium/src/Aquality.Selenium/Aquality.Selenium.csproj

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
<ItemGroup>
2727
<None Remove="Resources\JavaScripts\ExpandShadowRoot.js" />
28+
<None Remove="Resources\JavaScripts\GetDevicePixelRatio.js" />
29+
<None Remove="Resources\JavaScripts\GetElementsFromPoint.js" />
2830
<None Remove="Resources\JavaScripts\GetElementCssSelector.js" />
2931
<None Remove="Resources\JavaScripts\SetAttribute.js" />
3032
<None Remove="Resources\Localization\be.json" />
@@ -47,6 +49,8 @@
4749
<EmbeddedResource Include="Resources\JavaScripts\GetCheckBoxState.js" />
4850
<EmbeddedResource Include="Resources\JavaScripts\GetComboBoxSelectedText.js" />
4951
<EmbeddedResource Include="Resources\JavaScripts\GetComboBoxTexts.js" />
52+
<EmbeddedResource Include="Resources\JavaScripts\GetDevicePixelRatio.js" />
53+
<EmbeddedResource Include="Resources\JavaScripts\GetElementsFromPoint.js" />
5054
<EmbeddedResource Include="Resources\JavaScripts\GetElementByXPath.js" />
5155
<EmbeddedResource Include="Resources\JavaScripts\ExpandShadowRoot.js" />
5256
<EmbeddedResource Include="Resources\JavaScripts\GetElementText.js" />
@@ -78,8 +82,13 @@
7882
</ItemGroup>
7983

8084
<ItemGroup>
81-
<PackageReference Include="Aquality.Selenium.Core" Version="3.0.5" />
85+
<PackageReference Include="Aquality.Selenium.Core" Version="3.0.6" />
8286
<PackageReference Include="WebDriverManager" Version="2.17.1" />
87+
<PackageReference Include="OpenCvSharp4" Version="4.9.0.20240103" />
88+
<PackageReference Include="OpenCvSharp4.runtime.linux-arm" Version="4.9.0.20240103" />
89+
<PackageReference Include="OpenCvSharp4.runtime.osx.10.15-x64" Version="4.6.0.20230105" />
90+
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.9.0.20240103" />
91+
<PackageReference Include="OpenCvSharp4_.runtime.ubuntu.20.04-x64" Version="4.9.0.20240103" />
8392
</ItemGroup>
8493

8594
</Project>

Aquality.Selenium/src/Aquality.Selenium/Aquality.Selenium.xml

Lines changed: 59 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Aquality.Selenium/src/Aquality.Selenium/Browsers/DevToolsHandling.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public void CloseDevToolsSession()
6161

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

91+
/// <summary>
92+
/// Creates a session to communicate with a browser using the Chromium Developer Tools debugging protocol.
93+
/// </summary>
94+
/// <param name="options"> The options for the DevToolsSession to use.
95+
/// Defaults to autodetect the protocol version for <see cref="ChromiumDriver"/>, V85 for FirefoxDriver.</param>
96+
/// <returns>The active session to use to communicate with the Chromium Developer Tools debugging protocol.</returns>
97+
public DevToolsSession GetDevToolsSession(DevToolsOptions options)
98+
{
99+
Logger.Info("loc.browser.devtools.session.get", options.ProtocolVersion?.ToString() ?? "default");
100+
var session = devToolsProvider.GetDevToolsSession(options);
101+
wasDevToolsSessionClosed = false;
102+
return session;
103+
}
104+
90105
/// <summary>
91106
/// Executes a custom Chromium Dev Tools Protocol Command.
92107
/// Note: works only if current driver is instance of <see cref="ChromiumDriver"/>.

Aquality.Selenium/src/Aquality.Selenium/Browsers/JavaScript.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ public enum JavaScript
1515
GetCheckBoxState,
1616
GetComboBoxSelectedText,
1717
GetComboBoxTexts,
18+
GetDevicePixelRatio,
1819
GetElementByXPath,
20+
GetElementsFromPoint,
1921
GetElementText,
2022
GetElementXPath,
2123
GetElementCssSelector,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using Aquality.Selenium.Browsers;
2+
using Aquality.Selenium.Core.Configurations;
3+
using OpenCvSharp;
4+
using OpenQA.Selenium;
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Collections.ObjectModel;
8+
using System.IO;
9+
using System.Linq;
10+
11+
namespace Aquality.Selenium.Elements.Interfaces
12+
{
13+
/// <summary>
14+
/// Locator to search elements by image.
15+
/// Takes screenshot and finds match using openCV.
16+
/// Then finds elements by coordinates using JavaScript.
17+
/// </summary>
18+
public class ByImage : By, IDisposable
19+
{
20+
private readonly Mat template;
21+
private readonly string description;
22+
23+
/// <summary>
24+
/// Constructor accepting image file.
25+
/// </summary>
26+
/// <param name="file">Image file to locate element by.</param>
27+
public ByImage(FileInfo file)
28+
{
29+
description= file.Name;
30+
template = new Mat(file.FullName, ImreadModes.Unchanged);
31+
}
32+
33+
/// <summary>
34+
/// Constructor accepting image bytes.
35+
/// </summary>
36+
/// <param name="bytes">Image bytes to locate element by.</param>
37+
public ByImage(byte[] bytes)
38+
{
39+
description = $"bytes[%d]";
40+
template = Mat.ImDecode(bytes, ImreadModes.Unchanged);
41+
}
42+
43+
/// <summary>
44+
/// Threshold of image similarity.
45+
/// Should be a float between 0 and 1, where 1 means 100% match, and 0.5 means 50% match.
46+
/// </summary>
47+
public virtual float Threshold { get; set; } = 1 - AqualityServices.Get<IVisualizationConfiguration>().DefaultThreshold;
48+
49+
public override IWebElement FindElement(ISearchContext context)
50+
{
51+
return FindElements(context)?.FirstOrDefault()
52+
?? throw new NoSuchElementException($"Cannot locate an element using {ToString()}");
53+
}
54+
55+
public override ReadOnlyCollection<IWebElement> FindElements(ISearchContext context)
56+
{
57+
var source = GetScreenshot(context);
58+
var result = new Mat();
59+
60+
Cv2.MatchTemplate(source, template, result, TemplateMatchModes.CCoeffNormed);
61+
62+
Cv2.MinMaxLoc(result, out _, out var maxVal, out _, out var matchLocation);
63+
var matchCounter = Math.Abs((result.Width - template.Width + 1) * (result.Height - template.Height + 1));
64+
var matchLocations = new List<Point>();
65+
while (matchCounter > 0 && maxVal >= Threshold)
66+
{
67+
matchCounter--;
68+
matchLocations.Add(matchLocation);
69+
Cv2.Rectangle(result, new Rect(matchLocation.X, matchLocation.Y, template.Width, template.Height), Scalar.Black, -1);
70+
Cv2.MinMaxLoc(result, out _, out maxVal, out _, out matchLocation);
71+
}
72+
73+
return matchLocations.Select(match => GetElementOnPoint(match, context)).ToList().AsReadOnly();
74+
}
75+
76+
/// <summary>
77+
/// Gets a single element on point (find by center coordinates, then select closest to matchLocation).
78+
/// </summary>
79+
/// <param name="matchLocation">Location of the upper-left point of the element.</param>
80+
/// <param name="context">Search context.
81+
/// If the searchContext is Locatable (like WebElement), will adjust coordinates to be absolute coordinates.</param>
82+
/// <returns>The closest found element.</returns>
83+
protected virtual IWebElement GetElementOnPoint(Point matchLocation, ISearchContext context)
84+
{
85+
if (context is ILocatable locatable)
86+
{
87+
var point = locatable.Coordinates.LocationInDom;
88+
matchLocation = matchLocation.Add(new Point(point.X, point.Y));
89+
}
90+
var centerLocation = matchLocation.Add(new Point(template.Width / 2, template.Height / 2));
91+
92+
var elements = AqualityServices.Browser.ExecuteScript<IList<IWebElement>>(JavaScript.GetElementsFromPoint, centerLocation.X, centerLocation.Y)
93+
.OrderBy(element => DistanceToPoint(matchLocation, element));
94+
return elements.First();
95+
}
96+
97+
/// <summary>
98+
/// Calculates distance from element to matching point.
99+
/// </summary>
100+
/// <param name="matchLocation">Matching point.</param>
101+
/// <param name="element">Target element.</param>
102+
/// <returns>Distance in pixels.</returns>
103+
protected virtual double DistanceToPoint(Point matchLocation, IWebElement element)
104+
{
105+
var elementLocation = element.Location;
106+
return Math.Sqrt(Math.Pow(matchLocation.X - elementLocation.X, 2) + Math.Pow(matchLocation.Y - elementLocation.Y, 2));
107+
}
108+
109+
/// <summary>
110+
/// Takes screenshot from searchContext if supported, or from browser.
111+
/// Performs screenshot scaling if devicePixelRatio != 1.
112+
/// </summary>
113+
/// <param name="context">Search context for element location.</param>
114+
/// <returns>Captured screenshot as Mat object.</returns>
115+
protected virtual Mat GetScreenshot(ISearchContext context)
116+
{
117+
var screenshotBytes = context is ITakesScreenshot
118+
? (context as ITakesScreenshot).GetScreenshot().AsByteArray
119+
: AqualityServices.Browser.GetScreenshot();
120+
var isBrowserScreenshot = context is IWebDriver || !(context is ITakesScreenshot);
121+
var source = Mat.ImDecode(screenshotBytes, ImreadModes.Unchanged);
122+
var devicePixelRatio = AqualityServices.Browser.ExecuteScript<long>(JavaScript.GetDevicePixelRatio);
123+
if (devicePixelRatio != 1 && isBrowserScreenshot)
124+
{
125+
var scaledWidth = (int)(source.Width / devicePixelRatio);
126+
var scaledHeight = (int)(source.Height / devicePixelRatio);
127+
Cv2.Resize(source, source, new Size(scaledWidth, scaledHeight), interpolation: InterpolationFlags.Area);
128+
}
129+
130+
return source;
131+
}
132+
133+
public override string ToString()
134+
{
135+
return $"ByImage: {description}, size: {template.Size()}";
136+
}
137+
138+
public override bool Equals(object obj)
139+
{
140+
ByImage by = obj as ByImage;
141+
return by != null && template.ToString().Equals(by.template?.ToString());
142+
}
143+
144+
public override int GetHashCode()
145+
{
146+
return template.GetHashCode();
147+
}
148+
149+
public void Dispose()
150+
{
151+
Dispose(true);
152+
GC.SuppressFinalize(this);
153+
}
154+
155+
protected virtual void Dispose(bool disposing)
156+
{
157+
if (disposing)
158+
{
159+
template.Dispose();
160+
}
161+
}
162+
}
163+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
return window.devicePixelRatio;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
return document.elementsFromPoint(arguments[0], arguments[1]);

Aquality.Selenium/tests/Aquality.Selenium.Tests/Aquality.Selenium.Tests.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,17 @@
3434
<PrivateAssets>all</PrivateAssets>
3535
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
3636
</PackageReference>
37-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
37+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
3838
</ItemGroup>
3939

4040
<ItemGroup>
4141
<ProjectReference Include="..\..\src\Aquality.Selenium\Aquality.Selenium.csproj" />
4242
</ItemGroup>
4343

4444
<ItemGroup>
45+
<None Update="Resources\BrokenImage.png">
46+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
47+
</None>
4548
<None Update="Resources\settings.azure.json">
4649
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
4750
</None>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using Aquality.Selenium.Browsers;
2+
using Aquality.Selenium.Elements.Interfaces;
3+
using Aquality.Selenium.Tests.Integration.TestApp.TheInternet.Forms;
4+
using NUnit.Framework;
5+
using OpenQA.Selenium;
6+
7+
namespace Aquality.Selenium.Tests.Integration
8+
{
9+
internal class ImageLocatorTests : UITest
10+
{
11+
private readonly BrokenImagesForm form = new BrokenImagesForm();
12+
13+
[Test]
14+
public void Should_BePossibleTo_FindByImage()
15+
{
16+
new CheckBoxesForm().Open();
17+
Assert.That(form.LabelByImage.State.IsDisplayed, Is.False, "Should be impossible to find element on page by image when it is absent");
18+
form.Open();
19+
Assert.That(form.LabelByImage.State.IsDisplayed, "Should be possible to find element on page by image");
20+
Assert.That(form.LabelByImage.GetElement().TagName, Is.EqualTo("img"), "Correct element must be found");
21+
22+
var childLabels = form.ChildLabelsByImage;
23+
var docLabels = form.LabelsByImage;
24+
Assert.That(docLabels.Count, Is.GreaterThan(1), "List of elements should be possible to find by image");
25+
Assert.That(docLabels.Count, Is.EqualTo(childLabels.Count), "Should be possible to find child elements by image with the same count");
26+
27+
var documentByTag = AqualityServices.Get<IElementFactory>().GetLabel(By.TagName("body"), "document by tag");
28+
var fullThreshold = 1;
29+
var documentByImage = AqualityServices.Get<IElementFactory>().GetLabel(new ByImage(documentByTag.GetElement().GetScreenshot().AsByteArray) { Threshold = fullThreshold },
30+
"body screen");
31+
Assert.That(documentByImage.State.IsDisplayed, "Should be possible to find element by document screenshot");
32+
Assert.That((documentByImage.Locator as ByImage)?.Threshold, Is.EqualTo(fullThreshold), "Should be possible to get ByImage threshold");
33+
Assert.That(documentByImage.GetElement().TagName, Is.EqualTo("body"), "Correct element must be found");
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)