diff --git a/src/main/java/io/visual_regression_tracker/sdk_java/PathProvider.java b/src/main/java/io/visual_regression_tracker/sdk_java/PathProvider.java index e75358f..29112a6 100644 --- a/src/main/java/io/visual_regression_tracker/sdk_java/PathProvider.java +++ b/src/main/java/io/visual_regression_tracker/sdk_java/PathProvider.java @@ -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; diff --git a/src/main/java/io/visual_regression_tracker/sdk_java/TestRunException.java b/src/main/java/io/visual_regression_tracker/sdk_java/TestRunException.java index 69e28dd..67304b1 100644 --- a/src/main/java/io/visual_regression_tracker/sdk_java/TestRunException.java +++ b/src/main/java/io/visual_regression_tracker/sdk_java/TestRunException.java @@ -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."; @@ -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: diff --git a/src/main/java/io/visual_regression_tracker/sdk_java/TestRunOptions.java b/src/main/java/io/visual_regression_tracker/sdk_java/TestRunOptions.java index a093d49..cc0c785 100755 --- a/src/main/java/io/visual_regression_tracker/sdk_java/TestRunOptions.java +++ b/src/main/java/io/visual_regression_tracker/sdk_java/TestRunOptions.java @@ -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; } diff --git a/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTracker.java b/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTracker.java index c77ed59..a9c635d 100755 --- a/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTracker.java +++ b/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTracker.java @@ -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; @@ -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, @@ -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; @@ -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 response = getResponse(METHOD.POST, paths.getBuildPath(), body); + HttpResponse response = getResponse(METHOD.POST, paths.getBuildPath(), JSON_CONTENT_TYPE, body); BuildResponse buildResponse = handleResponse(response, BuildResponse.class); buildId = buildResponse.getId(); @@ -94,7 +106,7 @@ public BuildResponse stop() throws IOException, InterruptedException { .isRunning(false) .build(); HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofString(gson.toJson(stopBuildRequest)); - HttpResponse response = getResponse(METHOD.PATCH, paths.getBuildPathForBuild(buildId), body); + HttpResponse 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); @@ -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 parts = multipartBody(boundary, fields, image, filename); + HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.fromPublisher( + HttpRequest.BodyPublishers.ofByteArrays(parts), parts.stream().mapToLong(part -> part.length).sum()); + HttpResponse 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: @@ -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 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 response = getResponse(METHOD.POST, paths.getTestRunPath(), body); - return handleResponse(response, TestRunResponse.class); + private static List multipartBody(String boundary, JsonObject fields, byte[] image, String filename) { + StringBuilder head = new StringBuilder(); + for (Map.Entry 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 getResponse(METHOD method, String url, HttpRequest.BodyPublisher body) throws IOException, InterruptedException { + private HttpResponse 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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + Optional 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 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) { @@ -191,9 +285,13 @@ protected HttpRequest getRequest(METHOD method, HttpRequest.BodyPublisher body, } protected T handleResponse(HttpResponse response, Class classOfT) { + return handleResponse(response, classOfT, false); + } + + private T handleResponse(HttpResponse response, Class 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); } diff --git a/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfig.java b/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfig.java index bc807cf..09e1ca5 100644 --- a/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfig.java +++ b/src/main/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfig.java @@ -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(); @@ -49,6 +56,7 @@ public static class VisualRegressionTrackerConfigBuilder { private String ciBuildId; private Boolean enableSoftAssert; private Integer httpTimeoutInSeconds; + private String sourceBranch; private File configFile; @@ -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; @@ -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) { diff --git a/src/main/java/io/visual_regression_tracker/sdk_java/request/TestRunRequest.java b/src/main/java/io/visual_regression_tracker/sdk_java/request/TestRunRequest.java index 6f310ad..d57bc26 100755 --- a/src/main/java/io/visual_regression_tracker/sdk_java/request/TestRunRequest.java +++ b/src/main/java/io/visual_regression_tracker/sdk_java/request/TestRunRequest.java @@ -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; diff --git a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfigTest.java b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfigTest.java index 3496a32..c8c949b 100644 --- a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfigTest.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerConfigTest.java @@ -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 @@ -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() @@ -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)); @@ -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); diff --git a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java new file mode 100644 index 0000000..35182a2 --- /dev/null +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java @@ -0,0 +1,167 @@ +package io.visual_regression_tracker.sdk_java; + +import com.google.gson.Gson; +import io.visual_regression_tracker.sdk_java.response.TestRunResponse; +import lombok.SneakyThrows; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.endsWith; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.testng.Assert.expectThrows; + +public class VisualRegressionTrackerMultipartTest { + + private static final String BUILD_ID = "buildId"; + private static final String PROJECT_ID = "projectId"; + private static final byte[] IMAGE = "PNG bytes".getBytes(StandardCharsets.UTF_8); + + private final Gson gson = new Gson(); + private MockWebServer mockWebServer; + private VisualRegressionTrackerConfig config; + private VisualRegressionTracker vrt; + + @SneakyThrows + @BeforeMethod + public void setup() { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + config = new VisualRegressionTrackerConfig(mockWebServer.url("/").toString(), "key", "project", "develop", null, false, 1); + vrt = new VisualRegressionTracker(config); + vrt.buildId = BUILD_ID; + vrt.projectId = PROJECT_ID; + } + + @SneakyThrows + @AfterMethod + public void tearDown() { + mockWebServer.shutdown(); + } + + @Test + public void trackBytesShouldPostEveryOptionAsMultipartForm() throws Exception { + config.setSourceBranch("release"); + enqueueStatus(TestRunStatus.OK); + + vrt.track("Home page", IMAGE, TestRunOptions.builder() + .os("Linux") + .browser("Chrome") + .viewport("800x600") + .device("Desktop") + .customTags("smoke") + .comment("first run") + .merge(true) + .diffTollerancePercent(0.5f) + .ignoreAreas(List.of(IgnoreAreas.builder().x(1L).y(2L).width(3L).height(4L).build())) + .build()); + + RecordedRequest request = mockWebServer.takeRequest(); + String body = request.getBody().readUtf8(); + assertThat(request.getMethod(), is("POST")); + assertThat(request.getPath(), endsWith("/test-runs/multipart")); + assertThat(request.getHeader("Content-Type"), startsWith("multipart/form-data; boundary=")); + assertThat(request.getHeader("Content-Length"), is(String.valueOf(request.getBodySize()))); + assertThat(request.getHeader(VisualRegressionTracker.API_KEY_HEADER), is("key")); + assertThat(body, containsString(field("projectId", PROJECT_ID))); + assertThat(body, containsString(field("buildId", BUILD_ID))); + assertThat(body, containsString(field("name", "Home page"))); + assertThat(body, containsString(field("branchName", "develop"))); + assertThat(body, containsString(field("sourceBranch", "release"))); + assertThat(body, containsString(field("os", "Linux"))); + assertThat(body, containsString(field("browser", "Chrome"))); + assertThat(body, containsString(field("viewport", "800x600"))); + assertThat(body, containsString(field("device", "Desktop"))); + assertThat(body, containsString(field("customTags", "smoke"))); + assertThat(body, containsString(field("comment", "first run"))); + assertThat(body, containsString(field("merge", "true"))); + assertThat(body, containsString(field("diffTollerancePercent", "0.5"))); + assertThat(body, containsString(field("ignoreAreas", "[{\"x\":1,\"y\":2,\"width\":3,\"height\":4}]"))); + assertThat(body, containsString(image("image.png", "PNG bytes"))); + assertThat(body, not(containsString("imageBase64"))); + } + + @Test + public void trackBytesShouldLeaveOutOptionsThatAreNotSet() throws Exception { + enqueueStatus(TestRunStatus.OK); + + vrt.track("Home page", IMAGE); + + String body = mockWebServer.takeRequest().getBody().readUtf8(); + assertThat(body, not(containsString("name=\"os\""))); + assertThat(body, not(containsString("name=\"comment\""))); + assertThat(body, not(containsString("name=\"merge\""))); + assertThat(body, not(containsString("name=\"sourceBranch\""))); + } + + @Test + public void trackPathShouldUploadTheFileUnderItsName() throws Exception { + Path screenshot = Files.createTempDirectory("vrt").resolve("home.png"); + Files.write(screenshot, IMAGE); + enqueueStatus(TestRunStatus.OK); + + vrt.track("Home page", screenshot); + + RecordedRequest request = mockWebServer.takeRequest(); + assertThat(request.getPath(), endsWith("/test-runs/multipart")); + assertThat(request.getBody().readUtf8(), containsString(image("home.png", "PNG bytes"))); + } + + @Test + public void trackBytesShouldFailOnDifferenceLikeBase64() { + mockWebServer.enqueue(new MockResponse().setBody(gson.toJson(TestRunResponse.builder() + .status(TestRunStatus.UNRESOLVED) + .url("http://localhost:8080/test/1") + .build()))); + + TestRunException exception = expectThrows(TestRunException.class, () -> vrt.track("Home page", IMAGE)); + + assertThat(exception.getMessage(), is("Difference found: http://localhost:8080/test/1")); + } + + @Test + public void payloadTooLargeOnMultipartShouldPointAtTheMultipartLimit() { + mockWebServer.enqueue(new MockResponse().setResponseCode(413).setBody("{\"message\":\"File too large\"}")); + + TestRunException exception = expectThrows(TestRunException.class, () -> vrt.track("Home page", IMAGE)); + + assertThat(exception.getStatusCode(), is(413)); + assertThat(exception.getMessage(), + is("Request failed with status 413: File too large. " + TestRunException.MULTIPART_TOO_LARGE_HINT)); + } + + @Test + public void payloadTooLargeOnBase64ShouldSuggestMultipart() { + mockWebServer.enqueue(new MockResponse().setResponseCode(413).setBody("{\"message\":\"request entity too large\"}")); + + TestRunException exception = expectThrows(TestRunException.class, () -> vrt.track("Home page", "aW1hZ2U=")); + + assertThat(exception.getMessage(), + is("Request failed with status 413: request entity too large. " + TestRunException.PAYLOAD_TOO_LARGE_HINT)); + } + + private void enqueueStatus(TestRunStatus status) { + mockWebServer.enqueue(new MockResponse().setBody(gson.toJson(TestRunResponse.builder().status(status).build()))); + } + + private static String field(String name, String value) { + return "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n"; + } + + private static String image(String filename, String content) { + return "Content-Disposition: form-data; name=\"image\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: image/png\r\n\r\n" + content + "\r\n"; + } +} diff --git a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java new file mode 100644 index 0000000..fde1c5a --- /dev/null +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java @@ -0,0 +1,124 @@ +package io.visual_regression_tracker.sdk_java; + +import com.google.gson.Gson; +import io.visual_regression_tracker.sdk_java.response.BuildResponse; +import lombok.SneakyThrows; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.testng.Assert.expectThrows; + +public class VisualRegressionTrackerRetryTest { + + private final Gson gson = new Gson(); + private final List waits = new ArrayList<>(); + private MockWebServer mockWebServer; + private VisualRegressionTracker vrt; + + @SneakyThrows + @BeforeMethod + public void setup() { + waits.clear(); + mockWebServer = new MockWebServer(); + mockWebServer.start(); + vrt = new VisualRegressionTracker(new VisualRegressionTrackerConfig( + mockWebServer.url("/").toString(), "key", "project", "develop", null, false, 1)) { + @Override + void sleep(Duration duration) { + waits.add(duration); + } + }; + } + + @SneakyThrows + @AfterMethod + public void tearDown() { + mockWebServer.shutdown(); + } + + @Test + public void shouldRetryServiceUnavailableAfterTheTimeTheServerAsks() throws Exception { + mockWebServer.enqueue(busy("2")); + mockWebServer.enqueue(started()); + + BuildResponse build = vrt.start(); + + assertThat(build.getId(), is("buildId")); + assertThat(mockWebServer.getRequestCount(), is(2)); + assertThat(waits, is(List.of(Duration.ofSeconds(2)))); + } + + @Test + public void shouldCapTheWaitBetweenRetries() throws Exception { + mockWebServer.enqueue(busy("3600")); + mockWebServer.enqueue(started()); + + vrt.start(); + + assertThat(waits, is(List.of(VisualRegressionTracker.MAX_RETRY_AFTER))); + } + + @Test + public void shouldGiveUpAfterTheLastRetry() { + for (int i = 0; i <= VisualRegressionTracker.MAX_RETRIES; i++) { + mockWebServer.enqueue(busy("0")); + } + + TestRunException exception = expectThrows(TestRunException.class, () -> vrt.start()); + + assertThat(exception.getStatusCode(), is(503)); + assertThat(mockWebServer.getRequestCount(), is(VisualRegressionTracker.MAX_RETRIES + 1)); + } + + @Test + public void shouldNotRetryServiceUnavailableWithoutRetryAfter() { + mockWebServer.enqueue(new MockResponse().setResponseCode(503).setBody("{\"message\":\"Directory service is not available.\"}")); + mockWebServer.enqueue(started()); + + TestRunException exception = expectThrows(TestRunException.class, () -> vrt.start()); + + assertThat(exception.getStatusCode(), is(503)); + assertThat(mockWebServer.getRequestCount(), is(1)); + } + + @Test + public void shouldNotRetryOtherErrorsWithRetryAfter() { + mockWebServer.enqueue(new MockResponse().setResponseCode(429).setHeader("Retry-After", "0")); + mockWebServer.enqueue(started()); + + expectThrows(TestRunException.class, () -> vrt.start()); + + assertThat(mockWebServer.getRequestCount(), is(1)); + } + + @Test + public void shouldNotRetryWhenRetryAfterIsNotSeconds() { + mockWebServer.enqueue(busy("Wed, 21 Oct 2026 07:28:00 GMT")); + mockWebServer.enqueue(started()); + + TestRunException exception = expectThrows(TestRunException.class, () -> vrt.start()); + + assertThat(exception.getStatusCode(), is(503)); + assertThat(mockWebServer.getRequestCount(), is(1)); + } + + private static MockResponse busy(String retryAfter) { + return new MockResponse() + .setResponseCode(503) + .setHeader("Retry-After", retryAfter) + .setBody("{\"message\":\"Image diff queue is full (100 jobs)\"}"); + } + + private MockResponse started() { + return new MockResponse().setBody(gson.toJson(BuildResponse.builder().id("buildId").projectId("projectId").build())); + } +} diff --git a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerTest.java b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerTest.java index 84c4d25..a06e231 100755 --- a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerTest.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerTest.java @@ -157,6 +157,8 @@ public void shouldSubmitTestRun() throws IOException, InterruptedException { .browser("Browser") .viewport("Viewport") .customTags("customTags") + .comment("comment") + .merge(true) .diffTollerancePercent(0.5f) .ignoreAreas(Arrays.asList(IgnoreAreas.builder() .x(100L) @@ -176,6 +178,9 @@ public void shouldSubmitTestRun() throws IOException, InterruptedException { .viewport(testRunOptions.getViewport()) .device(testRunOptions.getDevice()) .customTags(testRunOptions.getCustomTags()) + .comment(testRunOptions.getComment()) + .merge(testRunOptions.getMerge()) + .sourceBranch("release") .diffTollerancePercent(testRunOptions.getDiffTollerancePercent()) .ignoreAreas(testRunOptions.getIgnoreAreas()) .build(); @@ -183,6 +188,7 @@ public void shouldSubmitTestRun() throws IOException, InterruptedException { .status(TestRunStatus.UNRESOLVED) .build(); mockWebServer.enqueue(new MockResponse().setBody(gson.toJson(testRunResponse))); + config.setSourceBranch("release"); vrt.buildId = BUILD_ID; vrt.projectId = PROJECT_ID; @@ -458,6 +464,29 @@ public void httpTimeoutWorks() throws IOException, InterruptedException { assertThat(vrt.projectId, is(PROJECT_ID)); } + @Test + public void zeroHttpTimeoutShouldMeanNoLimit() throws IOException, InterruptedException { + vrt = new VisualRegressionTracker(new VisualRegressionTrackerConfig( + mockWebServer.url("/").toString(), "key", "project", "develop", null, false, 0)); + mockWebServer.enqueue(new MockResponse().setBody(gson.toJson(BuildResponse.builder() + .id(BUILD_ID) + .projectId(PROJECT_ID) + .build()))); + + vrt.start(); + + assertThat(vrt.buildId, is(BUILD_ID)); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void negativeHttpTimeoutShouldBeRejected() throws IOException, InterruptedException { + vrt = new VisualRegressionTracker(new VisualRegressionTrackerConfig( + mockWebServer.url("/").toString(), "key", "project", "develop", null, false, -1)); + mockWebServer.enqueue(new MockResponse().setBody(gson.toJson(BuildResponse.builder().id(BUILD_ID).build()))); + + vrt.start(); + } + @Test(expectedExceptions = UnsupportedOperationException.class, expectedExceptionsMessageRegExp = "This method is not yet supported.") public void methodNotSupported() { vrt.getRequest(METHOD.GET, null, null); diff --git a/src/test/resources/vrt_config_example.json b/src/test/resources/vrt_config_example.json index 8f407f4..17e988c 100644 --- a/src/test/resources/vrt_config_example.json +++ b/src/test/resources/vrt_config_example.json @@ -5,5 +5,6 @@ "branchName": "master", "enableSoftAssert": false, "ciBuildId": "SOME_UNIQUE_ID", - "httpTimeoutInSeconds": 15 + "httpTimeoutInSeconds": 15, + "sourceBranch": "develop" }