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

Ensure build is shared across all test classes #12

Merged
merged 30 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions visual-dotnet/SauceLabs.Visual.Tests/BuildFactoryTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using NUnit.Framework;
using RichardSzalay.MockHttp;
using SauceLabs.Visual.GraphQL;
using SauceLabs.Visual.Models;

namespace SauceLabs.Visual.Tests;

public class BuildFactoryTest
{
MockHttpMessageHandler MockedHandler;
private const string _username = "dummy-username";
private const string _accessKey = "dummy-key";

[SetUp]
public void Setup()
{
MockedHandler = new MockHttpMessageHandler();

var createHandler = () =>
{
var id = RandomString(32);
var content = new VisualBuild(id, $"http://dummy/test/{id}", BuildMode.Running);
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ReadOnlyMemoryContent(ToResult(content));
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return Task.FromResult(resp);
};

var base64EncodedAuthenticationString =
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_username}:{_accessKey}"));
var regions = new[] { Region.Staging, Region.UsEast4, Region.EuCentral1, Region.UsWest1 };
foreach (var r in regions)
{
MockedHandler
.When(r.Value.ToString())
.WithHeaders($"Authorization: Basic {base64EncodedAuthenticationString}")
.WithPartialContent($"\"operationName\":\"{CreateBuildMutation.OperationName}\"")
.Respond(createHandler);
MockedHandler
.When(r.Value.ToString())
.WithHeaders($"Authorization: Basic {base64EncodedAuthenticationString}")
.WithPartialContent($"\"operationName\":\"{FinishBuildMutation.OperationName}\"")
.Respond(createHandler);
}
MockedHandler.Fallback.Throw(new InvalidOperationException("No matching mock handler"));
}

[TearDown]
public void Cleanup()
{
MockedHandler.Dispose();
}

private string RandomString(int length)
{
const string chars = "abcdef0123456789";
return new string(Enumerable.Repeat(chars, length).Select(s => s[Random.Shared.Next(s.Length)]).ToArray());
}

private byte[] ToResult(object o)
{
var nestedContent = new
{
Data = new
{
Result = o
},
};
return Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(nestedContent));
}

[Test]
public async Task BuildFactory_ReturnSameBuildWhenSameRegion()
{
var api = new VisualApi(Region.Staging, _username, _accessKey, MockedHandler.ToHttpClient());
var build1 = await BuildFactory.Get(api, new CreateBuildOptions());
var build2 = await BuildFactory.Get(api, new CreateBuildOptions());
Assert.AreEqual(build1, build2);
}

[Test]
public async Task BuildFactory_ReturnDifferentBuildWhenDifferentRegion()
{
var api1 = new VisualApi(Region.Staging, _username, _accessKey, MockedHandler.ToHttpClient());
var api2 = new VisualApi(Region.UsEast4, _username, _accessKey, MockedHandler.ToHttpClient());
var build1 = await BuildFactory.Get(api1, new CreateBuildOptions());
var build2 = await BuildFactory.Get(api2, new CreateBuildOptions());
Assert.AreNotEqual(build1, build2);
}
}
4 changes: 2 additions & 2 deletions visual-dotnet/SauceLabs.Visual.Tests/VisualApiTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace SauceLabs.Visual.Tests;

public class VisualApiTest
{
internal VisualApi<MockedWebDriver>? Api { get; set; }
internal VisualApi? Api { get; set; }
internal MockedWebDriver? WdMock { get; set; }
internal MockHttpMessageHandler? MockedHandler { get; set; }

Expand All @@ -27,7 +27,7 @@ public void Setup()

var caps = new MockedCapabilities(new Dictionary<string, object> { { "jobUuid", _jobUuid } });
WdMock = new MockedWebDriver(caps, _jobUuid);
Api = new VisualApi<MockedWebDriver>(WdMock, Region.Staging, _username, _accessKey, MockedHandler.ToHttpClient());
Api = new VisualApi(Region.Staging, _username, _accessKey, MockedHandler.ToHttpClient());
}

[Test]
Expand Down
14 changes: 14 additions & 0 deletions visual-dotnet/SauceLabs.Visual/ApiBuildPair.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace SauceLabs.Visual
{
public class ApiBuildPair
{
internal VisualBuild Build { get; }
internal VisualApi Api { get; }

internal ApiBuildPair(VisualApi api, VisualBuild build)
{
Build = build;
Api = api;
}
}
}
182 changes: 182 additions & 0 deletions visual-dotnet/SauceLabs.Visual/BuildFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SauceLabs.Visual.GraphQL;
using SauceLabs.Visual.Utils;

namespace SauceLabs.Visual
{
internal static class BuildFactory
{
private static readonly Dictionary<string, ApiBuildPair> Builds = new Dictionary<string, ApiBuildPair>();

/// <summary>
/// <c>Get</c> returns the build matching with the requested region.
/// If none is available, it returns a newly created build with <c>options</c>.
/// It will also clone the input <c>api</c> to be able to close the build later.
/// </summary>
/// <param name="api">the api to use to create build</param>
/// <param name="options">the options to use when creating the build</param>
/// <returns></returns>
internal static async Task<VisualBuild> Get(VisualApi api, CreateBuildOptions options)
{
// Check if there is already a build for the current region.
if (Builds.TryGetValue(api.Region.Name, out var build))
{
return build.Build;
}

var createdBuild = await Create(api, options);
Builds[api.Region.Name] = new ApiBuildPair(api.Clone(), createdBuild);
return createdBuild;
}

/// <summary>
/// <c>FindRegionByBuild</c> returns the region matching the passed build.
/// </summary>
/// <param name="build"></param>
/// <returns>the matching region name</returns>
private static string? FindRegionByBuild(VisualBuild build)
{
return Builds.Where(n => n.Value.Build == build).Select(n => n.Key).FirstOrDefault();
}

/// <summary>
/// <c>Close</c> finishes and forget about <c>build</c>
/// </summary>
/// <param name="build">the build to finish</param>
internal static async Task Close(VisualBuild build)
{
var key = FindRegionByBuild(build);
if (key != null)
{
await Close(key, Builds[key]);
}
}

/// <summary>
/// <c>Close</c> finishes and forget about <c>build</c>
/// </summary>
/// <param name="region">the build to finish</param>
/// <param name="entry">the api/build pair</param>
private static async Task Close(string region, ApiBuildPair entry)
{
if (!entry.Build.IsExternal)
{
await entry.Api.FinishBuild(entry.Build.Id);
}
Builds.Remove(region);
entry.Api.Dispose();
}

/// <summary>
/// <c>CloseBuilds</c> closes all build that are still open.
/// </summary>
internal static async Task CloseBuilds()
{
var regions = Builds.Keys;
foreach (var region in regions)
{
await Close(region, Builds[region]);
}
}

/// <summary>
/// <c>FindBuildById</c> returns the build identified by <c>buildId</c>
/// </summary>
/// <param name="api">a <c>VisualApi</c> object</param>
/// <param name="buildId">the <c>buildId</c> to look for</param>
/// <returns>the matching build</returns>
/// <exception cref="VisualClientException">when build is not existing or has an invalid state</exception>
private static async Task<VisualBuild> FindBuildById(VisualApi api, string buildId)
{
try
{
var build = (await api.Build(buildId)).EnsureValidResponse().Result;
return new VisualBuild(build.Id, build.Url, build.Mode);
}
catch (VisualClientException)
{
throw new VisualClientException($@"build {buildId} was not found");
}
}

/// <summary>
/// <c>FindBuildById</c> returns the build identified by <c>buildId</c>
/// </summary>
/// <param name="api">a <c>VisualApi</c> object</param>
/// <param name="customId">the <c>customId</c> to look for</param>
/// <returns>the matching build</returns>
/// <exception cref="VisualClientException">when build is not existing or has an invalid state</exception>
private static async Task<VisualBuild?> FindBuildByCustomId(VisualApi api, string customId)
{
try
{
var build = (await api.BuildByCustomId(customId)).EnsureValidResponse().Result;
return new VisualBuild(build.Id, build.Url, build.Mode);
}
catch (VisualClientException)
{
return null;
}
}

/// <summary>
/// <c>CreateBuild</c> creates a new Visual build.
/// </summary>
/// <param name="api">the api client used for the build creation</param>
/// <param name="options">the options for the build creation</param>
/// <returns>a <c>VisualBuild</c> instance</returns>
private static async Task<VisualBuild> Create(VisualApi api, CreateBuildOptions options)
{
var build = await GetEffectiveBuild(api, EnvVars.BuildId, EnvVars.CustomId);
if (build != null)
{
if (!build.IsRunning())
{
throw new VisualClientException($"build {build.Id} is not RUNNING");
}

build.IsExternal = true;
return build;
}

options.CustomId ??= EnvVars.CustomId;
var result = (await api.CreateBuild(new CreateBuildIn
{
Name = options.Name,
Project = options.Project,
Branch = options.Branch,
CustomId = options.CustomId,
DefaultBranch = options.DefaultBranch,
})).EnsureValidResponse();

build = new VisualBuild(result.Result.Id, result.Result.Url, result.Result.Mode)
{
IsExternal = false
};
return build;
}

/// <summary>
/// <c>GetEffectiveBuild</c> tries to find the build matching the criterion provided by the user.
/// </summary>
/// <param name="api"></param>
/// <param name="buildId"></param>
/// <param name="customId"></param>
/// <returns></returns>
private static async Task<VisualBuild?> GetEffectiveBuild(VisualApi api, string? buildId, string? customId)
{
if (!StringUtils.IsNullOrEmpty(buildId))
{
return await FindBuildById(api, buildId!.Trim());
}

if (!StringUtils.IsNullOrEmpty(customId))
{
return await FindBuildByCustomId(api, customId!.Trim());
}
return null;
}
}
}
15 changes: 9 additions & 6 deletions visual-dotnet/SauceLabs.Visual/Region.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ namespace SauceLabs.Visual
/// </summary>
public class Region
{
private Region(Uri value)
private Region(string name, Uri value)
{
Value = value;
Name = name;
}

private Region(string value)
private Region(string name, string value)
{
Name = name;
Value = new Uri(value);
}

public Uri Value { get; }
public string Name { get; }

public override string ToString()
{
Expand All @@ -43,9 +46,9 @@ public static Region FromName(string name)
};
}

public static Region UsWest1 => new Region("https://api.us-west-1.saucelabs.com/v1/visual/graphql");
public static Region UsEast4 => new Region("https://api.us-east-4.saucelabs.com/v1/visual/graphql");
public static Region EuCentral1 => new Region("https://api.eu-central-1.saucelabs.com/v1/visual/graphql");
public static Region Staging => new Region("https://api.staging.saucelabs.net/v1/visual/graphql");
public static Region UsWest1 => new Region("us-west-1", "https://api.us-west-1.saucelabs.com/v1/visual/graphql");
public static Region UsEast4 => new Region("us-east-4", "https://api.us-east-4.saucelabs.com/v1/visual/graphql");
public static Region EuCentral1 => new Region("eu-central-1", "https://api.eu-central-1.saucelabs.com/v1/visual/graphql");
public static Region Staging => new Region("staging", "https://api.staging.saucelabs.net/v1/visual/graphql");
}
}
13 changes: 10 additions & 3 deletions visual-dotnet/SauceLabs.Visual/VisualApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,28 @@
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.Newtonsoft;
using Newtonsoft.Json;
using OpenQA.Selenium;
using SauceLabs.Visual.GraphQL;
using SauceLabs.Visual.Utils;

namespace SauceLabs.Visual
{
internal class VisualApi<T> : IDisposable where T : IHasCapabilities, IHasSessionId
internal class VisualApi : IDisposable
{
internal readonly Region Region;
private readonly string _username;
private readonly string _accessKey;
private readonly GraphQLHttpClient _graphQlClient;

public VisualApi(T webdriver, Region region, string username, string accessKey, HttpClient? httpClient = null)
public VisualApi(Region region, string username, string accessKey, HttpClient? httpClient = null)
{

if (StringUtils.IsNullOrEmpty(username) || StringUtils.IsNullOrEmpty(accessKey))
{
throw new VisualClientException(
"Invalid SauceLabs credentials. Please check your SauceLabs username and access key at https://app.saucelabs.com/user-setting");
}

Region = region;
_username = username.Trim();
_accessKey = accessKey.Trim();

Expand Down Expand Up @@ -117,5 +119,10 @@ public void Dispose()
{
_graphQlClient.Dispose();
}

internal VisualApi Clone()
{
return new VisualApi(Region, _username, _accessKey);
}
}
}
Loading