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

Add support for SAUCE_VISUAL_BUILD_ID and SAUCE_VISUAL_CUSTOM_ID #6

Merged
merged 18 commits into from
Mar 19, 2024
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