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

C#: Adds ConcurrentVisualClient #186

Merged
merged 17 commits into from
Jan 23, 2025
115 changes: 115 additions & 0 deletions visual-dotnet/SauceLabs.Visual/ConcurrentVisualClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using OpenQA.Selenium;
using SauceLabs.Visual.GraphQL;
using SauceLabs.Visual.Utils;

namespace SauceLabs.Visual
{
/// <summary>
/// <c>VisualClient</c> provides an access to Sauce Labs Visual services.
/// </summary>
[Obsolete("This is an unstable API. It may change in the future.")]
public class ConcurrentVisualClient : VisualClientBase
{
// Stores ConcurrentVisualClient, indexed by region
private static readonly ConcurrentDictionary<Region, ConcurrentVisualClient> Instances = new ConcurrentDictionary<Region, ConcurrentVisualClient>();

// Stores session metadata, indexed by sessionId
private readonly ConcurrentDictionary<string, WebDriverSessionInfo> _sessionsMetadata = new ConcurrentDictionary<string, WebDriverSessionInfo>();

/// <summary>
/// Get the instance of <c>VisualClient</c> for <c>region</c>. Credentials are fetched from Environment.
/// </summary>
/// <param name="buildOptions">the options of the build creation</param>
/// <param name="region">target region</param>
public static async Task<ConcurrentVisualClient> Get(Region region, CreateBuildOptions buildOptions)
{
return await Get(region, VisualCredential.CreateFromEnvironment(), buildOptions);
}

/// <summary>
/// Get the instance of <c>VisualClient</c> for <c>region</c>
/// </summary>
/// <param name="buildOptions">the options of the build creation</param>
/// <param name="region">target region</param>
/// <param name="creds">Credentials to be used</param>
public static async Task<ConcurrentVisualClient> Get(Region region, VisualCredential creds, CreateBuildOptions buildOptions)
{
var client = await Instances.GetOrAddAsync(region, async (key) =>
{
var client = new ConcurrentVisualClient(key, creds);
client.Build = await BuildFactory.Get(client.Api, buildOptions);
return client;
});
return client;
}

/// <summary>
/// Close currently open build in all regions.
///
/// This should be invoked after all your test have processed.
/// <c>Finish</c> should be invoked within <c>[OneTimeTearDown]</c>
/// </summary>
public static async Task Finish()
{
var keys = Instances.Keys;
foreach (var key in keys)
{
Instances.TryRemove(key, out var client);

await client.Api.FinishBuild(client.Build.Id);
client.Dispose();
}
}

/// <summary>
/// Creates a new instance of <c>VisualClient</c>
/// </summary>
/// <param name="region">the Sauce Labs region to connect to</param>
/// <param name="creds">the Sauce Labs credentials</param>
private ConcurrentVisualClient(Region region, VisualCredential creds) : base(region, creds.Username, creds.AccessKey)
{
}

/// <summary>
/// <c>VisualCheck</c> captures a screenshot and queue it for processing.
/// </summary>
/// <param name="wd">the webdriver instance where the snapshot should be taken</param>
/// <param name="name">the name of the screenshot</param>
/// <param name="options">the configuration for the screenshot capture and comparison</param>
/// <param name="callerMemberName">the member name of the caller (automated) </param>
/// <returns></returns>
public Task<string> VisualCheck(
WebDriver wd,
string name,
VisualCheckOptions? options = null,
[CallerMemberName] string callerMemberName = "")
{
options ??= new VisualCheckOptions();
options.EnsureTestContextIsPopulated(callerMemberName, PreviousSuiteName);
PreviousSuiteName = options.SuiteName;
return VisualCheckAsync(wd, name, options);
}

private async Task<string> VisualCheckAsync(
WebDriver wd,
string name,
VisualCheckOptions options)
{

var sessionId = wd.SessionId.ToString();
var jobId = wd.Capabilities.HasCapability("jobUuid") ? wd.Capabilities.GetCapability("jobUuid").ToString() : sessionId;

var sessionMetadata = await _sessionsMetadata.GetOrAddAsync(sessionId, async (_) =>
{
var res = await Api.WebDriverSessionInfo(sessionId, jobId);
return res.EnsureValidResponse().Result;
});

return await VisualCheckBaseAsync(name, options, jobId, sessionId, sessionMetadata.Blob);
}
}
}
43 changes: 43 additions & 0 deletions visual-dotnet/SauceLabs.Visual/Utils/DictionaryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace SauceLabs.Visual.Utils
{
internal static class DictionaryExtensions
{
private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1);

public static async Task<TValue> GetOrAddAsync<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key, Func<TKey, Task<TValue>> valueProvider)
{
await Semaphore.WaitAsync();
try
{
if (dict == null)
{
throw new ArgumentNullException(nameof(dict));
}
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
if (valueProvider == null)
{
throw new ArgumentNullException(nameof(valueProvider));
}
if (dict.TryGetValue(key, out var foundValue))
{
return foundValue;
}

dict[key] = await valueProvider(key);
return dict[key];
}
finally
{
Semaphore.Release();
}
}
}
}
7 changes: 5 additions & 2 deletions visual-dotnet/SauceLabs.Visual/VisualApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ public VisualApi(Region region, string username, string accessKey, HttpClient? h

httpClient ??= new HttpClient();

var serializerOptions = new JsonSerializerSettings();
serializerOptions.Converters.Add(new ConstantCaseEnumConverter());
var serializerOptions = new JsonSerializerSettings()
{
NullValueHandling = NullValueHandling.Ignore,
Converters = { new ConstantCaseEnumConverter() }
};

var currentAssembly = Assembly.GetExecutingAssembly().GetName();
_graphQlClient = new GraphQLHttpClient(
Expand Down
111 changes: 6 additions & 105 deletions visual-dotnet/SauceLabs.Visual/VisualClient.cs
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using OpenQA.Selenium;
using Polly;
using Polly.Retry;
using SauceLabs.Visual.GraphQL;
using SauceLabs.Visual.Models;
using SauceLabs.Visual.Utils;

namespace SauceLabs.Visual
{
/// <summary>
/// <c>VisualClient</c> provides an access to Sauce Labs Visual services.
/// </summary>
public class VisualClient : IDisposable
public class VisualClient : VisualClientBase
{
internal readonly VisualApi Api;
private readonly string _sessionId;
private readonly string _jobId;
private string? _sessionMetadataBlob;
private readonly List<string> _screenshotIds = new List<string>();
public VisualBuild Build { get; private set; }
public bool CaptureDom { get; set; } = false;
public BaselineOverride? BaselineOverride { get; set; }
private readonly ResiliencePipeline _retryPipeline;

private string? _previousSuiteName = null;

/// <summary>
/// Creates a new instance of <c>VisualClient</c>
Expand All @@ -35,7 +20,7 @@
/// <param name="wd">the instance of the WebDriver session</param>
public static async Task<VisualClient> Create(WebDriver wd)
{
return await Create(wd, Region.FromEnvironment(), EnvVars.Username, EnvVars.AccessKey, new CreateBuildOptions());

Check warning on line 23 in visual-dotnet/SauceLabs.Visual/VisualClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'username' in 'Task<VisualClient> VisualClient.Create(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)'.

Check warning on line 23 in visual-dotnet/SauceLabs.Visual/VisualClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'accessKey' in 'Task<VisualClient> VisualClient.Create(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)'.
}

/// <summary>
Expand All @@ -45,7 +30,7 @@
/// <param name="buildOptions">the options of the build creation</param>
public static async Task<VisualClient> Create(WebDriver wd, CreateBuildOptions buildOptions)
{
return await Create(wd, Region.FromEnvironment(), EnvVars.Username, EnvVars.AccessKey, buildOptions);

Check warning on line 33 in visual-dotnet/SauceLabs.Visual/VisualClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'username' in 'Task<VisualClient> VisualClient.Create(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)'.

Check warning on line 33 in visual-dotnet/SauceLabs.Visual/VisualClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'accessKey' in 'Task<VisualClient> VisualClient.Create(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)'.
}

/// <summary>
Expand All @@ -55,7 +40,7 @@
/// <param name="region">the Sauce Labs region to connect to</param>
public static async Task<VisualClient> Create(WebDriver wd, Region region)
{
return await Create(wd, region, EnvVars.Username, EnvVars.AccessKey, new CreateBuildOptions());

Check warning on line 43 in visual-dotnet/SauceLabs.Visual/VisualClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'username' in 'Task<VisualClient> VisualClient.Create(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)'.

Check warning on line 43 in visual-dotnet/SauceLabs.Visual/VisualClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'accessKey' in 'Task<VisualClient> VisualClient.Create(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)'.
}

/// <summary>
Expand All @@ -66,7 +51,7 @@
/// <param name="buildOptions">the options of the build creation</param>
public static async Task<VisualClient> Create(WebDriver wd, Region region, CreateBuildOptions buildOptions)
{
return await Create(wd, region, EnvVars.Username, EnvVars.AccessKey, buildOptions);

Check warning on line 54 in visual-dotnet/SauceLabs.Visual/VisualClient.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'username' in 'Task<VisualClient> VisualClient.Create(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)'.
}

/// <summary>
Expand Down Expand Up @@ -112,26 +97,11 @@
/// <param name="region">the Sauce Labs region to connect to</param>
/// <param name="username">the Sauce Labs username</param>
/// <param name="accessKey">the Sauce Labs access key</param>
private VisualClient(WebDriver wd, Region region, string username, string accessKey)
private VisualClient(WebDriver wd, Region region, string username, string accessKey) : base(region, username, accessKey)
{
if (StringUtils.IsNullOrEmpty(username) || StringUtils.IsNullOrEmpty(accessKey))
{
throw new VisualClientException("Username or Access Key not set");
}

Api = new VisualApi(region, username, accessKey);
_sessionId = wd.SessionId.ToString();
_jobId = wd.Capabilities.HasCapability("jobUuid") ? wd.Capabilities.GetCapability("jobUuid").ToString() : _sessionId;

_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions()
{
Name = "VisualRetryPolicy",
Delay = TimeSpan.FromSeconds(1),
MaxRetryAttempts = 10
})
.AddTimeout(TimeSpan.FromSeconds(15))
.Build();
}

/// <summary>
Expand All @@ -154,40 +124,14 @@
[CallerMemberName] string callerMemberName = "")
{
options ??= new VisualCheckOptions();
options.EnsureTestContextIsPopulated(callerMemberName, _previousSuiteName);
_previousSuiteName = options.SuiteName;
options.EnsureTestContextIsPopulated(callerMemberName, PreviousSuiteName);
PreviousSuiteName = options.SuiteName;
return VisualCheckAsync(name, options);
}

private async Task<string> VisualCheckAsync(string name, VisualCheckOptions options)
{
var ignoredRegions = IgnoredRegions.SplitIgnoredRegions(options.Regions, options.IgnoreRegions, options.IgnoreElements);

FullPageConfigIn? fullPageConfigIn = null;
if (options.FullPage == true)
{
fullPageConfigIn = (options.FullPageConfig ?? new FullPageConfig()).ToFullPageConfigIn();
}

var result = (await Api.CreateSnapshotFromWebDriver(new CreateSnapshotFromWebDriverIn(
buildUuid: Build.Id,
name: name,
jobId: _jobId,
diffingMethod: options.DiffingMethod ?? DiffingMethod.Balanced,
regions: ignoredRegions.RegionsIn,
ignoredElements: ignoredRegions.ElementsIn,
sessionId: _sessionId,
sessionMetadata: _sessionMetadataBlob ?? "",
captureDom: options.CaptureDom ?? CaptureDom,
clipElement: options.ClipElement?.GetElementId(),
suiteName: options.SuiteName,
testName: options.TestName,
fullPageConfig: fullPageConfigIn,
diffingOptions: options.DiffingOptions?.ToDiffingOptionsIn(),
baselineOverride: (options.BaselineOverride ?? BaselineOverride)?.ToBaselineOverrideIn()
))).EnsureValidResponse();
result.Result.Diffs.Nodes.ToList().ForEach(d => _screenshotIds.Add(d.Id));
return result.Result.Id;
return await VisualCheckBaseAsync(name, options, _jobId, _sessionId, _sessionMetadataBlob);
}

/// <summary>
Expand All @@ -205,48 +149,5 @@
{
await BuildFactory.CloseBuilds();
}

public void Dispose()
{
Api.Dispose();
}

/// <summary>
/// <c>VisualResults</c> returns the results of screenshot comparison.
/// </summary>
/// <returns>a dictionary containing <c>DiffStatus</c> and the number of screenshot in that status.</returns>
/// <exception cref="VisualClientException"></exception>
public async Task<Dictionary<DiffStatus, int>> VisualResults()
{
return await _retryPipeline.ExecuteAsync(async token => await FetchVisualResults(Build.Id));
}

private async Task<Dictionary<DiffStatus, int>> FetchVisualResults(string buildId)
{
var dict = new Dictionary<DiffStatus, int>() {
{ DiffStatus.Approved, 0 },
{ DiffStatus.Equal, 0 },
{ DiffStatus.Unapproved, 0 },
{ DiffStatus.Errored, 0 },
{ DiffStatus.Queued, 0 },
{ DiffStatus.Rejected, 0 }
};

var result = (await Api.DiffForTestResult(buildId)).EnsureValidResponse();
result.Result.Nodes
.Where(n => _screenshotIds.Contains(n.Id))
.Aggregate(dict, (counts, node) =>
{
counts[node.Status] += 1;
return counts;
});

if (dict[DiffStatus.Queued] > 0)
{
throw new VisualClientException("Some diffs are not ready");
}

return dict;
}
}
}
}
Loading
Loading