Skip to content

Commit

Permalink
Add support for SAUCE_VISUAL_BUILD_ID and SAUCE_VISUAL_CUSTOM_ID (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
FriggaHel authored Mar 19, 2024
1 parent 265432d commit 12de446
Show file tree
Hide file tree
Showing 13 changed files with 242 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,36 @@ public async Task TestCreateBuild()
Assert.AreEqual("project-name", resp?.Data.Result.Project);
Assert.AreEqual("branch-name", resp?.Data.Result.Branch);
}

[Test]
public async Task TestBuildByBuildIdWithValidMode()
{
MockedHandler.Clear();
var base64EncodedAuthenticationString =
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_username}:{_accessKey}"));
MockedHandler
.Expect(HttpMethod.Post, "https://api.staging.saucelabs.net/v1/visual/*")
.WithHeaders($"Authorization: Basic {base64EncodedAuthenticationString}")
.WithPartialContent($"\"operationName\":\"{BuildQuery.OperationName}\"")
.Respond("application/json", "{\"data\":{\"result\":{\"id\":\"buildId\",\"url\": \"https://app.staging.saucelabs.net/visual/builds/fd54fb6f-83b7-4c0b-af8d-da2b191c0a3b\",\"name\":\"dummy-build\",\"mode\":\"COMPLETED\"}}}");
var resp = await Api.Build("buildId");
Assert.IsNotNull(resp.Data);
Assert.IsNotNull(resp.Data.Result);
}

[Test]
public async Task TestBuildByBuildIdWithInvalidMode()
{
MockedHandler.Clear();
var base64EncodedAuthenticationString =
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_username}:{_accessKey}"));
MockedHandler
.Expect(HttpMethod.Post, "https://api.staging.saucelabs.net/v1/visual/*")
.WithHeaders($"Authorization: Basic {base64EncodedAuthenticationString}")
.WithPartialContent($"\"operationName\":\"{BuildQuery.OperationName}\"")
.Respond("application/json", "{\"data\":{\"result\":{\"id\":\"buildId\",\"url\": \"https://app.staging.saucelabs.net/visual/builds/fd54fb6f-83b7-4c0b-af8d-da2b191c0a3b\",\"name\":\"dummy-build\",\"mode\":\"COMPLETED\"}}}");
var resp = await Api.Build("buildId");
Assert.IsNotNull(resp.Data);
Assert.IsNotNull(resp.Data.Result);
}
}
25 changes: 25 additions & 0 deletions visual-dotnet/SauceLabs.Visual/GraphQL/Build.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Newtonsoft.Json;
using SauceLabs.Visual.Models;

namespace SauceLabs.Visual.GraphQL
{
internal class Build
{
[JsonProperty("id")]
public string Id { get; }
[JsonProperty("name")]
public string Name { get; }
[JsonProperty("url")]
public string Url { get; }
[JsonProperty("mode")]
public BuildMode Mode { get; }

public Build(string id, string name, string url, BuildMode mode)
{
Id = id;
Name = name;
Url = url;
Mode = mode;
}
}
}
18 changes: 18 additions & 0 deletions visual-dotnet/SauceLabs.Visual/GraphQL/BuildByCustomIdQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace SauceLabs.Visual.GraphQL
{
internal static class BuildByCustomIdQuery
{
public const string OperationName = "buildByCustomId";

public const string OperationDocument = @"
query buildByCustomId($input: String!) {
result: buildByCustomId(customId: $input) {
id,
url,
name,
mode
}
}
";
}
}
18 changes: 18 additions & 0 deletions visual-dotnet/SauceLabs.Visual/GraphQL/BuildQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace SauceLabs.Visual.GraphQL
{
internal static class BuildQuery
{
public const string OperationName = "build";

public const string OperationDocument = @"
query build($input: UUID!) {
result: build(id: $input) {
id,
url,
name,
mode
}
}
";
}
}
6 changes: 5 additions & 1 deletion visual-dotnet/SauceLabs.Visual/GraphQL/CreateBuild.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Newtonsoft.Json;
using SauceLabs.Visual.Models;

namespace SauceLabs.Visual.GraphQL
{
Expand All @@ -10,6 +11,8 @@ internal class CreateBuild
public string Name { get; }
[JsonProperty("url")]
public string Url { get; }
[JsonProperty("mode")]
public BuildMode Mode { get; }
[JsonProperty("project")]
public string? Project { get; }
[JsonProperty("branch")]
Expand All @@ -19,11 +22,12 @@ internal class CreateBuild
[JsonProperty("defaultBranch")]
public string? DefaultBranch { get; }

public CreateBuild(string id, string name, string url, string? project, string? branch, string? customId, string? defaultBranch)
public CreateBuild(string id, string name, string url, BuildMode mode, string? project, string? branch, string? customId, string? defaultBranch)
{
Id = id;
Name = name;
Url = url;
Mode = mode;
Project = project;
Branch = branch;
CustomId = customId;
Expand Down
15 changes: 0 additions & 15 deletions visual-dotnet/SauceLabs.Visual/GraphQL/FinishBuildResponse.cs

This file was deleted.

8 changes: 8 additions & 0 deletions visual-dotnet/SauceLabs.Visual/Models/BuildMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace SauceLabs.Visual.Models
{
public enum BuildMode
{
Running,
Completed
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ public override HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions
IGraphQLJsonSerializer serializer)
{
var message = base.ToHttpRequestMessage(options, serializer);
if (string.IsNullOrEmpty(Username) || string.IsNullOrEmpty(AccessKey))
if (StringUtils.IsNullOrEmpty(Username) || StringUtils.IsNullOrEmpty(AccessKey))
{
return message;
}

var authenticationString = $"{Username}:{AccessKey}";
var authenticationString = $"{Username!.Trim()}:{AccessKey!.Trim()}";
var base64EncodedAuthenticationString =
Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", base64EncodedAuthenticationString);
Expand Down
12 changes: 12 additions & 0 deletions visual-dotnet/SauceLabs.Visual/Utils/EnvVars.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace SauceLabs.Visual.Utils
{
internal class EnvVars
{
internal static string CustomId => Environment.GetEnvironmentVariable("SAUCE_VISUAL_CUSTOM_ID") ?? "";
internal static string BuildId => Environment.GetEnvironmentVariable("SAUCE_VISUAL_BUILD_ID") ?? "";
internal static string Username => Environment.GetEnvironmentVariable("SAUCE_USERNAME") ?? "";
internal static string AccessKey => Environment.GetEnvironmentVariable("SAUCE_ACCESS_KEY") ?? "";
}
}
15 changes: 15 additions & 0 deletions visual-dotnet/SauceLabs.Visual/Utils/StringUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace SauceLabs.Visual.Utils
{
public static class StringUtils
{
/// <summary>
/// <c>IsNullOrEmpty</c> checks that the string null, empty or contains only whitespaces.
/// </summary>
/// <param name="value">true if string is empty</param>
/// <returns></returns>
public static bool IsNullOrEmpty(string? value)
{
return string.IsNullOrEmpty(value?.Trim());
}
}
}
20 changes: 19 additions & 1 deletion visual-dotnet/SauceLabs.Visual/VisualApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internal class VisualApi<T> : IDisposable where T : IHasCapabilities, IHasSessio
public VisualApi(T webdriver, Region region, string username, string accessKey, HttpClient? httpClient = null)
{

if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(accessKey))
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");
Expand Down Expand Up @@ -64,6 +64,24 @@ public async Task<GraphQLResponse<ServerResponse<FinishBuild>>> FinishBuild(stri
return await _graphQlClient.SendQueryAsync<ServerResponse<FinishBuild>>(request);
}

public async Task<GraphQLResponse<ServerResponse<Build>>> Build(string buildId)
{
var request = CreateAuthenticatedRequest(BuildQuery.OperationDocument, BuildQuery.OperationName, new
{
input = buildId
});
return await _graphQlClient.SendQueryAsync<ServerResponse<Build>>(request);
}

public async Task<GraphQLResponse<ServerResponse<Build>>> BuildByCustomId(string customId)
{
var request = CreateAuthenticatedRequest(BuildByCustomIdQuery.OperationDocument, BuildByCustomIdQuery.OperationName, new
{
input = customId
});
return await _graphQlClient.SendQueryAsync<ServerResponse<Build>>(request);
}

public async Task<GraphQLResponse<ServerResponse<WebDriverSessionInfo>>> WebDriverSessionInfo(string jobId, string sessionId)
{
var request = CreateAuthenticatedRequest(WebDriverSessionInfoQuery.OperationDocument, WebDriverSessionInfoQuery.OperationName, new { jobId, sessionId });
Expand Down
9 changes: 8 additions & 1 deletion visual-dotnet/SauceLabs.Visual/VisualBuild.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using SauceLabs.Visual.Models;

namespace SauceLabs.Visual
{
/// <summary>
Expand All @@ -8,10 +10,15 @@ public class VisualBuild
public string Id { get; internal set; }
public string Url { get; internal set; }

internal VisualBuild(string id, string url)
public BuildMode Mode { get; internal set; }

internal VisualBuild(string id, string url, BuildMode mode)
{
Id = id;
Url = url;
Mode = mode;
}

internal bool IsRunning() => Mode == BuildMode.Running;
}
}
88 changes: 80 additions & 8 deletions visual-dotnet/SauceLabs.Visual/VisualClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ public class VisualClient : IDisposable
public VisualBuild Build { get; }
private readonly bool _externalBuild;
public bool CaptureDom { get; set; } = false;
private ResiliencePipeline _retryPipeline;
private readonly ResiliencePipeline _retryPipeline;

/// <summary>
/// Creates a new instance of <c>VisualClient</c>
/// </summary>
/// <param name="wd">the instance of the WebDriver session</param>
/// <param name="region">the Sauce Labs region to connect to</param>
public VisualClient(WebDriver wd, Region region) : this(wd, region, Environment.GetEnvironmentVariable("SAUCE_USERNAME"), Environment.GetEnvironmentVariable("SAUCE_ACCESS_KEY"))
public VisualClient(WebDriver wd, Region region) : this(wd, region, EnvVars.Username, EnvVars.AccessKey)
{
}

Expand All @@ -41,7 +41,7 @@ public VisualClient(WebDriver wd, Region region) : this(wd, region, Environment.
/// <param name="wd">the instance of the WebDriver session</param>
/// <param name="region">the Sauce Labs region to connect to</param>
/// <param name="buildOptions">the options of the build creation</param>
public VisualClient(WebDriver wd, Region region, CreateBuildOptions buildOptions) : this(wd, region, Environment.GetEnvironmentVariable("SAUCE_USERNAME"), Environment.GetEnvironmentVariable("SAUCE_ACCESS_KEY"), buildOptions)
public VisualClient(WebDriver wd, Region region, CreateBuildOptions buildOptions) : this(wd, region, EnvVars.Username, EnvVars.AccessKey, buildOptions)
{
}

Expand All @@ -66,7 +66,7 @@ public VisualClient(WebDriver wd, Region region, CreateBuildOptions buildOptions
/// <param name="buildOptions">the options of the build creation</param>
public VisualClient(WebDriver wd, Region region, string username, string accessKey, CreateBuildOptions buildOptions)
{
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(accessKey))
if (StringUtils.IsNullOrEmpty(username) || StringUtils.IsNullOrEmpty(accessKey))
{
throw new VisualClientException("Username or Access Key not set");
}
Expand All @@ -78,9 +78,23 @@ public VisualClient(WebDriver wd, Region region, string username, string accessK
var metadata = response.EnsureValidResponse();
_sessionMetadataBlob = metadata.Result.Blob;

var createBuildResponse = CreateBuild(buildOptions).Result;
Build = new VisualBuild(createBuildResponse.Id, createBuildResponse.Url);
_externalBuild = false;
var build = GetEffectiveBuild(EnvVars.BuildId, EnvVars.CustomId).Result;
if (build != null)
{
if (!build.IsRunning())
{
throw new VisualClientException($"build {build.Id} is not RUNNING");
}
Build = build;
_externalBuild = true;
}
else
{
buildOptions.CustomId ??= EnvVars.CustomId;
var createBuildResponse = CreateBuild(buildOptions).Result;
Build = new VisualBuild(createBuildResponse.Id, createBuildResponse.Url, createBuildResponse.Mode);
_externalBuild = false;
}

_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions()
Expand All @@ -93,6 +107,64 @@ public VisualClient(WebDriver wd, Region region, string username, string accessK
.Build();
}

/// <summary>
/// <c>FindBuildById</c> returns the build identified by <c>buildId</c>
/// </summary>
/// <param name="buildId"></param>
/// <returns>the matching build</returns>
/// <exception cref="VisualClientException">when build is not existing or has an invalid state</exception>
private async Task<VisualBuild> FindBuildById(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>FindBuildByCustomId</c> returns the build identified by <c>customId</c> or null if not found
/// </summary>
/// <param name="customId"></param>
/// <returns>the matching build or null</returns>
/// <exception cref="VisualClientException">when build has an invalid state</exception>
private async Task<VisualBuild?> FindBuildByCustomId(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>GetEffectiveBuild</c> tries to find the build matching the criterion provided by the user.
/// </summary>
/// <param name="buildId"></param>
/// <param name="customId"></param>
/// <returns></returns>
private async Task<VisualBuild?> GetEffectiveBuild(string? buildId, string? customId)
{
if (!StringUtils.IsNullOrEmpty(buildId))
{
return await FindBuildById(buildId!.Trim());
}

if (StringUtils.IsNullOrEmpty(customId))
{
return await FindBuildByCustomId(customId!.Trim());
}
return null;
}

/// <summary>
/// <c>CreateBuild</c> creates a new Visual build.
/// </summary>
Expand All @@ -108,7 +180,7 @@ private async Task<VisualBuild> CreateBuild(CreateBuildOptions? options = null)
CustomId = options?.CustomId,
DefaultBranch = options?.DefaultBranch,
})).EnsureValidResponse();
return new VisualBuild(result.Result.Id, result.Result.Url);
return new VisualBuild(result.Result.Id, result.Result.Url, result.Result.Mode);
}

/// <summary>
Expand Down

0 comments on commit 12de446

Please sign in to comment.