Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public String getTestRunPath() {
return baseApiUrl.concat(TEST_RUNS_PATH);
}

public String getTestRunMultipartPath() {
return getTestRunPath().concat("/multipart");
}

public String getImageUrl(String name) {
if(name == null || name.isEmpty()){
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
@Getter
public class TestRunException extends RuntimeException {
static final String PAYLOAD_TOO_LARGE_HINT =
"The request is above the server's JSON body limit (BODY_PARSER_JSON_LIMIT); send a smaller screenshot or raise the limit.";
"The request is above the server's JSON body limit (BODY_PARSER_JSON_LIMIT); track the image as a file or bytes (multipart) instead of base64, or raise the limit.";
static final String MULTIPART_TOO_LARGE_HINT =
"The image is above the server's upload limit (MULTIPART_FILE_SIZE_LIMIT); send a smaller screenshot or raise the limit.";
static final String IMAGE_TOO_LARGE_HINT =
"The image has more pixels than the server accepts (MAX_IMAGE_PIXELS); send a smaller screenshot or raise the limit.";

Expand All @@ -17,16 +19,20 @@ public TestRunException(String errorMessage) {
}

public TestRunException(int statusCode, String serverMessage) {
super(httpErrorMessage(statusCode, serverMessage));
this(statusCode, serverMessage, false);
}

TestRunException(int statusCode, String serverMessage, boolean multipart) {
super(httpErrorMessage(statusCode, serverMessage, multipart));
this.statusCode = statusCode;
}

private static String httpErrorMessage(int statusCode, String serverMessage) {
private static String httpErrorMessage(int statusCode, String serverMessage, boolean multipart) {
String detail = serverMessage.isBlank() ? "" : ": " + serverMessage;
String message = "Request failed with status " + statusCode + detail;
switch (statusCode) {
case 413:
return message + ". " + PAYLOAD_TOO_LARGE_HINT;
return message + ". " + (multipart ? MULTIPART_TOO_LARGE_HINT : PAYLOAD_TOO_LARGE_HINT);
case 422:
return message + ". " + IMAGE_TOO_LARGE_HINT;
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public class TestRunOptions {
private final String viewport;
private final String device;
private final String customTags;
private final String comment;
private final Boolean merge;
private final Float diffTollerancePercent;
private final List<IgnoreAreas> ignoreAreas;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.visual_regression_tracker.sdk_java;

import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import io.visual_regression_tracker.sdk_java.request.BuildRequest;
import io.visual_regression_tracker.sdk_java.request.ModifyBuildRequest;
Expand All @@ -16,7 +18,14 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

enum METHOD {
GET,
Expand All @@ -31,6 +40,9 @@ public class VisualRegressionTracker {
private static final String CONFIG_FILE_NAME = "vrt.json";
protected static final String API_KEY_HEADER = "apiKey";
protected static final String PROJECT_HEADER = "project";
private static final String JSON_CONTENT_TYPE = "application/json;charset=UTF-8";
static final int MAX_RETRIES = 3;
static final Duration MAX_RETRY_AFTER = Duration.ofSeconds(60);
protected Gson gson;
protected VisualRegressionTrackerConfig configuration;
protected PathProvider paths;
Expand Down Expand Up @@ -71,7 +83,7 @@ public BuildResponse start() throws IOException, InterruptedException {
.build();
log.info("Starting Visual Regression Tracker for project <{}> and branch <{}>", projectName, branch);
HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(gson.toJson(newBuild));
HttpResponse<String> response = getResponse(METHOD.POST, paths.getBuildPath(), body);
HttpResponse<String> response = getResponse(METHOD.POST, paths.getBuildPath(), JSON_CONTENT_TYPE, body);
BuildResponse buildResponse = handleResponse(response, BuildResponse.class);

buildId = buildResponse.getId();
Expand All @@ -94,7 +106,7 @@ public BuildResponse stop() throws IOException, InterruptedException {
.isRunning(false)
.build();
HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(gson.toJson(stopBuildRequest));
HttpResponse<String> response = getResponse(METHOD.PATCH, paths.getBuildPathForBuild(buildId), body);
HttpResponse<String> response = getResponse(METHOD.PATCH, paths.getBuildPathForBuild(buildId), JSON_CONTENT_TYPE, body);
BuildResponse vrtStopResponse = handleResponse(response, BuildResponse.class);

log.info("Visual Regression Tracker is stopped for buildId <{}>", buildId);
Expand All @@ -104,8 +116,46 @@ public BuildResponse stop() throws IOException, InterruptedException {
public TestRunResult track(String name, String imageBase64, TestRunOptions testRunOptions)
throws IOException, InterruptedException {
log.info("Tracking test run <{}> with options <{}> for buildId <{}>", name, testRunOptions, buildId);
TestRunResponse testResultDTO = submitTestRun(name, imageBase64, testRunOptions);
return toResult(submitTestRun(name, imageBase64, testRunOptions));
}

public TestRunResult track(String name, String imageBase64) throws IOException, InterruptedException {
return track(name, imageBase64, TestRunOptions.builder().build());
}

public TestRunResult track(String name, byte[] image, TestRunOptions testRunOptions)
throws IOException, InterruptedException {
return trackMultipart(name, image, "image.png", testRunOptions);
}

public TestRunResult track(String name, byte[] image) throws IOException, InterruptedException {
return track(name, image, TestRunOptions.builder().build());
}

public TestRunResult track(String name, Path image, TestRunOptions testRunOptions)
throws IOException, InterruptedException {
return trackMultipart(name, Files.readAllBytes(image), image.getFileName().toString(), testRunOptions);
}

public TestRunResult track(String name, Path image) throws IOException, InterruptedException {
return track(name, image, TestRunOptions.builder().build());
}

private TestRunResult trackMultipart(String name, byte[] image, String filename, TestRunOptions testRunOptions)
throws IOException, InterruptedException {
log.info("Tracking test run <{}> with options <{}> for buildId <{}>", name, testRunOptions, buildId);
JsonObject fields = gson.toJsonTree(testRunRequest(name, testRunOptions).build()).getAsJsonObject();
String boundary = UUID.randomUUID().toString();

List<byte[]> parts = multipartBody(boundary, fields, image, filename);
HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.fromPublisher(
HttpRequest.BodyPublishers.ofByteArrays(parts), parts.stream().mapToLong(part -> part.length).sum());
HttpResponse<String> response = getResponse(METHOD.POST, paths.getTestRunMultipartPath(),
"multipart/form-data; boundary=" + boundary, body);
return toResult(handleResponse(response, TestRunResponse.class, true));
}

private TestRunResult toResult(TestRunResponse testResultDTO) {
String errorMessage;
switch (testResultDTO.getStatus()) {
case NEW:
Expand Down Expand Up @@ -134,49 +184,93 @@ public TestRunResult track(String name, String imageBase64, TestRunOptions testR
return new TestRunResult(testResultDTO, this.paths);
}

public TestRunResult track(String name, String imageBase64) throws IOException, InterruptedException {
return track(name, imageBase64, TestRunOptions.builder().build());
}

protected boolean isStarted() {
return buildId != null && projectId != null;
}

protected TestRunResponse submitTestRun(String name, String imageBase64,
TestRunOptions testRunOptions) throws IOException, InterruptedException {
TestRunRequest newTestRun = testRunRequest(name, testRunOptions).imageBase64(imageBase64).build();

HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(gson.toJson(newTestRun));
HttpResponse<String> response = getResponse(METHOD.POST, paths.getTestRunPath(), JSON_CONTENT_TYPE, body);
return handleResponse(response, TestRunResponse.class);
}

private TestRunRequest.TestRunRequestBuilder testRunRequest(String name, TestRunOptions testRunOptions) {
if (!isStarted()) {
throw new TestRunException(TRACKER_NOT_STARTED);
}

TestRunRequest newTestRun = TestRunRequest.builder()
return TestRunRequest.builder()
.projectId(projectId)
.buildId(buildId)
.branchName(configuration.getBranchName() != null ? configuration.getBranchName() : branchName)
.sourceBranch(configuration.getSourceBranch())
.name(name)
.imageBase64(imageBase64)
.os(testRunOptions.getOs())
.browser(testRunOptions.getBrowser())
.viewport(testRunOptions.getViewport())
.device(testRunOptions.getDevice())
.customTags(testRunOptions.getCustomTags())
.comment(testRunOptions.getComment())
.merge(testRunOptions.getMerge())
.diffTollerancePercent(testRunOptions.getDiffTollerancePercent())
.ignoreAreas(testRunOptions.getIgnoreAreas())
.build();
.ignoreAreas(testRunOptions.getIgnoreAreas());
}

HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(gson.toJson(newTestRun));
HttpResponse<String> response = getResponse(METHOD.POST, paths.getTestRunPath(), body);
return handleResponse(response, TestRunResponse.class);
private static List<byte[]> multipartBody(String boundary, JsonObject fields, byte[] image, String filename) {
StringBuilder head = new StringBuilder();
for (Map.Entry<String, JsonElement> field : fields.entrySet()) {
JsonElement value = field.getValue();
head.append("--").append(boundary).append("\r\nContent-Disposition: form-data; name=\"").append(field.getKey())
.append("\"\r\n\r\n").append(value.isJsonPrimitive() ? value.getAsString() : value.toString()).append("\r\n");
}
head.append("--").append(boundary).append("\r\nContent-Disposition: form-data; name=\"image\"; filename=\"")
.append(filename.replace("\"", "%22")).append("\"\r\nContent-Type: image/png\r\n\r\n");
String tail = "\r\n--" + boundary + "--\r\n";
return List.of(head.toString().getBytes(StandardCharsets.UTF_8), image, tail.getBytes(StandardCharsets.UTF_8));
}

private HttpResponse<String> getResponse(METHOD method, String url, HttpRequest.BodyPublisher body) throws IOException, InterruptedException {
private HttpResponse<String> getResponse(METHOD method, String url, String contentType, HttpRequest.BodyPublisher body)
throws IOException, InterruptedException {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.timeout(Duration.ofSeconds(configuration.getHttpTimeoutInSeconds()))
.header(API_KEY_HEADER, configuration.getApiKey())
.header(PROJECT_HEADER, configuration.getProject())
.header("Content-Type", "application/json;charset=UTF-8")
.header("Content-Type", contentType)
.uri(URI.create(url));
if (configuration.getHttpTimeoutInSeconds() != 0) {
requestBuilder.timeout(Duration.ofSeconds(configuration.getHttpTimeoutInSeconds()));
}
HttpRequest request = getRequest(method, body, requestBuilder);
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
for (int retry = 1; ; retry++) {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
Optional<Duration> wait = retryAfter(response);
if (wait.isEmpty() || retry > MAX_RETRIES) {
return response;
}
log.warn("Server is busy ({}), retry {} of {} in {} s", serverMessage(response.body()), retry, MAX_RETRIES,
wait.get().getSeconds());
sleep(wait.get());
}
}

private static Optional<Duration> retryAfter(HttpResponse<?> response) {
if (response.statusCode() != 503) {
return Optional.empty();
}
try {
return response.headers().firstValue("Retry-After")
.map(Long::parseLong)
.filter(seconds -> seconds >= 0)
.map(seconds -> Duration.ofSeconds(Math.min(seconds, MAX_RETRY_AFTER.getSeconds())));
} catch (NumberFormatException e) {
return Optional.empty();
}
}

void sleep(Duration duration) throws InterruptedException {
Thread.sleep(duration.toMillis());
}

protected HttpRequest getRequest(METHOD method, HttpRequest.BodyPublisher body, HttpRequest.Builder requestBuilder) {
Expand All @@ -191,9 +285,13 @@ protected HttpRequest getRequest(METHOD method, HttpRequest.BodyPublisher body,
}

protected <T> T handleResponse(HttpResponse<String> response, Class<T> classOfT) {
return handleResponse(response, classOfT, false);
}

private <T> T handleResponse(HttpResponse<String> response, Class<T> classOfT, boolean multipart) {
String responseBody = response.body();
if (!String.valueOf(response.statusCode()).startsWith("2")) {
throw new TestRunException(response.statusCode(), serverMessage(responseBody));
throw new TestRunException(response.statusCode(), serverMessage(responseBody), multipart);
}
return gson.fromJson(responseBody, classOfT);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ public class VisualRegressionTrackerConfig {
private String ciBuildId;
private Boolean enableSoftAssert = false;
private int httpTimeoutInSeconds = 360;
private String sourceBranch;

public VisualRegressionTrackerConfig(@NonNull String apiUrl, @NonNull String apiKey, @NonNull String project,
String branchName, String ciBuildId, Boolean enableSoftAssert,
int httpTimeoutInSeconds) {
this(apiUrl, apiKey, project, branchName, ciBuildId, enableSoftAssert, httpTimeoutInSeconds, null);
}

public static VisualRegressionTrackerConfigBuilder builder() {
return new VisualRegressionTrackerConfigBuilder();
Expand All @@ -49,6 +56,7 @@ public static class VisualRegressionTrackerConfigBuilder {
private String ciBuildId;
private Boolean enableSoftAssert;
private Integer httpTimeoutInSeconds;
private String sourceBranch;

private File configFile;

Expand Down Expand Up @@ -89,6 +97,11 @@ public VisualRegressionTrackerConfigBuilder httpTimeoutInSeconds(int httpTimeout
return this;
}

public VisualRegressionTrackerConfigBuilder sourceBranch(String sourceBranch) {
this.sourceBranch = sourceBranch;
return this;
}

public VisualRegressionTrackerConfigBuilder configFile(File configFile) {
this.configFile = configFile;
return this;
Expand All @@ -107,6 +120,7 @@ public VisualRegressionTrackerConfig build() {
VisualRegressionTrackerConfig config = new VisualRegressionTrackerConfig(actualApiUrl, actualApiKey, actualProject);
config.setCiBuildId(resolve("ciBuildId", configFromFile));
config.setBranchName(resolve("branchName", configFromFile));
config.setSourceBranch(resolve("sourceBranch", configFromFile));

Boolean actualEnableSoftAssert = resolve("enableSoftAssert", configFromFile);
if (actualEnableSoftAssert != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public class TestRunRequest {
private final String viewport;
private final String device;
private final String customTags;
private final String comment;
private final Boolean merge;
private final String sourceBranch;
private final Float diffTollerancePercent;
private final String branchName;
private final List<IgnoreAreas> ignoreAreas;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public void shouldBeCreatedFromConfigFile() {
assertThat(config.getEnableSoftAssert(), is(false));
assertThat(config.getCiBuildId(), is("SOME_UNIQUE_ID"));
assertThat(config.getHttpTimeoutInSeconds(), is(15));
assertThat(config.getSourceBranch(), is("develop"));
}

@Test
Expand All @@ -68,7 +69,8 @@ public void shouldBeCreatedFromEnvironment() throws Exception {
.set("VRT_PROJECT", "VRT")
.set("VRT_BRANCHNAME", "master")
.set("VRT_ENABLESOFTASSERT", "false")
.set("VRT_CIBUILDID", "SOME_UNIQUE_ID");
.set("VRT_CIBUILDID", "SOME_UNIQUE_ID")
.set("VRT_SOURCEBRANCH", "develop");

VisualRegressionTrackerConfig config = environmentVariables.execute(() ->
VisualRegressionTrackerConfig.builder()
Expand All @@ -81,23 +83,27 @@ public void shouldBeCreatedFromEnvironment() throws Exception {
assertThat(config.getBranchName(), is("master"));
assertThat(config.getEnableSoftAssert(), is(false));
assertThat(config.getCiBuildId(), is("SOME_UNIQUE_ID"));
assertThat(config.getSourceBranch(), is("develop"));
}

@Test
public void shouldResolveFinalValuesInTheRightOrder() throws Exception {
EnvironmentVariables environmentVariables = new EnvironmentVariables("VRT_APIKEY", "ENV_SECRET")
.set("VRT_APIURL", "http://162.243.161.172:4201");
.set("VRT_APIURL", "http://162.243.161.172:4201")
.set("VRT_SOURCEBRANCH", "env");
File configFile = new File("src/test/resources/vrt_config_example.json");

VisualRegressionTrackerConfig config = environmentVariables.execute(() ->
VisualRegressionTrackerConfig.builder()
.apiUrl("http://localhost:4200")
.sourceBranch("builder")
.configFile(configFile)
.build()
);

assertThat(config.getApiKey(), is("ENV_SECRET"));
assertThat(config.getApiUrl(), is("http://localhost:4200"));
assertThat(config.getSourceBranch(), is("builder"));
assertThat(config.getProject(), is("VRT"));
assertThat(config.getBranchName(), is("master"));
assertThat(config.getEnableSoftAssert(), is(false));
Expand Down Expand Up @@ -159,6 +165,16 @@ public void shouldNameTheKeyOfAnInvalidValue() throws Exception {
.build();
}

@Test
public void shouldKeepTheConstructorWithoutSourceBranch() {
VisualRegressionTrackerConfig config = new VisualRegressionTrackerConfig(
"http://localhost:4200", "KEY", "PROJECT", "develop", "ci", true, 15);

assertThat(config.getBranchName(), is("develop"));
assertThat(config.getHttpTimeoutInSeconds(), is(15));
assertThat(config.getSourceBranch(), is((String) null));
}

private File configFile(String content) throws Exception {
Path configFile = Files.createTempFile("vrt", ".json");
Files.writeString(configFile, content);
Expand Down
Loading
Loading