Skip to content

Commit

Permalink
Ensure build is shared across all test classes (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
FriggaHel authored Mar 29, 2024
1 parent 22568ea commit 11350fb
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 120 deletions.
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

0 comments on commit 11350fb

Please sign in to comment.