From 77aa9503d6c0e1d98a3d0afb3f47fda7e2571108 Mon Sep 17 00:00:00 2001 From: Pavlo Strunkin Date: Mon, 2 Nov 2026 22:53:00 +0200 Subject: [PATCH 1/4] feat: sourceBranch, comment, merge, multipart upload and 503 retry - sourceBranch in the config (builder, VRT_SOURCEBRANCH, vrt.json): the branch whose baselines a new branch compares against. The 7-argument config constructor is kept. - TestRunOptions.comment and TestRunOptions.merge. - track(name, byte[]) and track(name, Path) upload through POST /test-runs/multipart, which is not bound by BODY_PARSER_JSON_LIMIT. The 413 hint names the limit of the endpoint that was used. - A 503 with Retry-After is retried up to 3 times, waiting at most 60 s each. - httpTimeoutInSeconds 0 means no read limit instead of failing every request. Co-Authored-By: Claude Opus 5.5 --- .../sdk_java/PathProvider.java | 5 + .../sdk_java/TestRunException.java | 14 +- .../sdk_java/TestRunOptions.java | 2 + .../sdk_java/VisualRegressionTracker.java | 141 +++++++++++++-- .../VisualRegressionTrackerConfig.java | 14 ++ .../sdk_java/request/TestRunRequest.java | 3 + .../sdk_java/MockHttpClient.java | 2 +- .../VisualRegressionTrackerConfigTest.java | 29 +++ .../VisualRegressionTrackerMultipartTest.java | 165 ++++++++++++++++++ .../VisualRegressionTrackerRetryTest.java | 113 ++++++++++++ .../sdk_java/VisualRegressionTrackerTest.java | 20 +++ 11 files changed, 484 insertions(+), 24 deletions(-) create mode 100644 src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java create mode 100644 src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java 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..927c0af 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 @@ -7,6 +7,7 @@ public class PathProvider { private static final String BUILD_PATH = "/builds"; private static final String TEST_RUNS_PATH = "/test-runs"; + static final String MULTIPART_PATH = "/multipart"; private static final String IMAGES_PATH = "/images/"; private final String baseApiUrl; @@ -23,6 +24,10 @@ public String getTestRunPath() { return baseApiUrl.concat(TEST_RUNS_PATH); } + public String getTestRunMultipartPath() { + return getTestRunPath().concat(MULTIPART_PATH); + } + 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..3f9c937 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; @@ -10,13 +12,20 @@ import io.visual_regression_tracker.sdk_java.response.TestRunResponse; import lombok.extern.slf4j.Slf4j; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.net.URI; 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.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,38 @@ 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 <{}> from {} with options <{}> for buildId <{}>", name, filename, testRunOptions, buildId); + return toResult(submitTestRunMultipart(name, image, filename, testRunOptions)); + } + + private TestRunResult toResult(TestRunResponse testResultDTO) { String errorMessage; switch (testResultDTO.getStatus()) { case NEW: @@ -134,49 +176,109 @@ 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); + } + + protected TestRunResponse submitTestRunMultipart(String name, byte[] image, String filename, + TestRunOptions testRunOptions) throws IOException, InterruptedException { + JsonObject fields = gson.toJsonTree(testRunRequest(name, testRunOptions).build()).getAsJsonObject(); + String boundary = UUID.randomUUID().toString(); + + HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofByteArray(multipartBody(boundary, fields, image, filename)); + HttpResponse response = getResponse(METHOD.POST, paths.getTestRunMultipartPath(), + "multipart/form-data; boundary=" + boundary, 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 byte[] multipartBody(String boundary, JsonObject fields, byte[] image, String filename) throws IOException { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + for (Map.Entry field : fields.entrySet()) { + JsonElement value = field.getValue(); + writeUtf8(body, "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + field.getKey() + "\"\r\n\r\n" + + (value.isJsonPrimitive() ? value.getAsString() : value.toString()) + "\r\n"); + } + writeUtf8(body, "--" + boundary + "\r\nContent-Disposition: form-data; name=\"image\"; filename=\"" + + filename.replace("\"", "%22") + "\"\r\nContent-Type: image/png\r\n\r\n"); + body.write(image); + writeUtf8(body, "\r\n--" + boundary + "--\r\n"); + return body.toByteArray(); + } + + private static void writeUtf8(ByteArrayOutputStream stream, String text) throws IOException { + stream.write(text.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 -> seconds > MAX_RETRY_AFTER.getSeconds() ? MAX_RETRY_AFTER : Duration.ofSeconds(seconds)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + protected void sleep(Duration duration) throws InterruptedException { + Thread.sleep(duration.toMillis()); } protected HttpRequest getRequest(METHOD method, HttpRequest.BodyPublisher body, HttpRequest.Builder requestBuilder) { @@ -193,7 +295,8 @@ protected HttpRequest getRequest(METHOD method, HttpRequest.BodyPublisher body, protected T handleResponse(HttpResponse response, Class classOfT) { String responseBody = response.body(); if (!String.valueOf(response.statusCode()).startsWith("2")) { - throw new TestRunException(response.statusCode(), serverMessage(responseBody)); + boolean multipart = response.uri().getPath().endsWith(PathProvider.MULTIPART_PATH); + 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/MockHttpClient.java b/src/test/java/io/visual_regression_tracker/sdk_java/MockHttpClient.java index 888bb0a..af71a54 100644 --- a/src/test/java/io/visual_regression_tracker/sdk_java/MockHttpClient.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/MockHttpClient.java @@ -106,7 +106,7 @@ public Optional sslSession() { @Override public URI uri() { - return null; + return URI.create("http://localhost/test-runs"); } @Override 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..f227789 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 @@ -159,6 +159,35 @@ public void shouldNameTheKeyOfAnInvalidValue() throws Exception { .build(); } + @Test + public void shouldReadSourceBranchFromEachSource() throws Exception { + VisualRegressionTrackerConfig fromBuilder = VisualRegressionTrackerConfig.builder() + .configFile(configFile(REQUIRED_FIELDS + "}")) + .sourceBranch("release") + .build(); + VisualRegressionTrackerConfig fromEnvironment = new EnvironmentVariables("VRT_SOURCEBRANCH", "release") + .execute(() -> VisualRegressionTrackerConfig.builder() + .configFile(configFile(REQUIRED_FIELDS + ", \"sourceBranch\": \"ignored\"}")) + .build()); + VisualRegressionTrackerConfig fromFile = VisualRegressionTrackerConfig.builder() + .configFile(configFile(REQUIRED_FIELDS + ", \"sourceBranch\": \"release\"}")) + .build(); + + assertThat(fromBuilder.getSourceBranch(), is("release")); + assertThat(fromEnvironment.getSourceBranch(), is("release")); + assertThat(fromFile.getSourceBranch(), is("release")); + } + + @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..5f3ba19 --- /dev/null +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java @@ -0,0 +1,165 @@ +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(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(), containsString("multipart")); + } + + 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..b143a57 --- /dev/null +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java @@ -0,0 +1,113 @@ +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 + protected 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)); + } + + 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..891e922 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,20 @@ 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 = UnsupportedOperationException.class, expectedExceptionsMessageRegExp = "This method is not yet supported.") public void methodNotSupported() { vrt.getRequest(METHOD.GET, null, null); From 24a156c47d81386558ddc4c7a2364d2db1d7e323 Mon Sep 17 00:00:00 2001 From: Pavlo Strunkin Date: Mon, 2 Nov 2026 22:55:29 +0200 Subject: [PATCH 2/4] refactor: send multipart without copying the image, pass the multipart hint from the caller Co-Authored-By: Claude Opus 5.5 --- .../sdk_java/PathProvider.java | 3 +- .../sdk_java/VisualRegressionTracker.java | 51 ++++++++----------- .../sdk_java/MockHttpClient.java | 2 +- .../VisualRegressionTrackerConfigTest.java | 29 +++-------- .../VisualRegressionTrackerMultipartTest.java | 3 +- .../VisualRegressionTrackerRetryTest.java | 2 +- src/test/resources/vrt_config_example.json | 3 +- 7 files changed, 37 insertions(+), 56 deletions(-) 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 927c0af..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 @@ -7,7 +7,6 @@ public class PathProvider { private static final String BUILD_PATH = "/builds"; private static final String TEST_RUNS_PATH = "/test-runs"; - static final String MULTIPART_PATH = "/multipart"; private static final String IMAGES_PATH = "/images/"; private final String baseApiUrl; @@ -25,7 +24,7 @@ public String getTestRunPath() { } public String getTestRunMultipartPath() { - return getTestRunPath().concat(MULTIPART_PATH); + return getTestRunPath().concat("/multipart"); } public String getImageUrl(String name) { 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 3f9c937..78a73b5 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 @@ -12,7 +12,6 @@ import io.visual_regression_tracker.sdk_java.response.TestRunResponse; import lombok.extern.slf4j.Slf4j; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.net.URI; @@ -23,6 +22,7 @@ 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; @@ -144,7 +144,13 @@ public TestRunResult track(String name, Path image) throws IOException, Interrup private TestRunResult trackMultipart(String name, byte[] image, String filename, TestRunOptions testRunOptions) throws IOException, InterruptedException { log.info("Tracking test run <{}> from {} with options <{}> for buildId <{}>", name, filename, testRunOptions, buildId); - return toResult(submitTestRunMultipart(name, image, filename, testRunOptions)); + JsonObject fields = gson.toJsonTree(testRunRequest(name, testRunOptions).build()).getAsJsonObject(); + String boundary = UUID.randomUUID().toString(); + + HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofByteArrays(multipartBody(boundary, fields, image, filename)); + 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) { @@ -189,17 +195,6 @@ protected TestRunResponse submitTestRun(String name, String imageBase64, return handleResponse(response, TestRunResponse.class); } - protected TestRunResponse submitTestRunMultipart(String name, byte[] image, String filename, - TestRunOptions testRunOptions) throws IOException, InterruptedException { - JsonObject fields = gson.toJsonTree(testRunRequest(name, testRunOptions).build()).getAsJsonObject(); - String boundary = UUID.randomUUID().toString(); - - HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofByteArray(multipartBody(boundary, fields, image, filename)); - HttpResponse response = getResponse(METHOD.POST, paths.getTestRunMultipartPath(), - "multipart/form-data; boundary=" + boundary, body); - return handleResponse(response, TestRunResponse.class); - } - private TestRunRequest.TestRunRequestBuilder testRunRequest(String name, TestRunOptions testRunOptions) { if (!isStarted()) { throw new TestRunException(TRACKER_NOT_STARTED); @@ -222,22 +217,17 @@ private TestRunRequest.TestRunRequestBuilder testRunRequest(String name, TestRun .ignoreAreas(testRunOptions.getIgnoreAreas()); } - private static byte[] multipartBody(String boundary, JsonObject fields, byte[] image, String filename) throws IOException { - ByteArrayOutputStream body = new ByteArrayOutputStream(); + 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(); - writeUtf8(body, "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + field.getKey() + "\"\r\n\r\n" - + (value.isJsonPrimitive() ? value.getAsString() : value.toString()) + "\r\n"); + 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"); } - writeUtf8(body, "--" + boundary + "\r\nContent-Disposition: form-data; name=\"image\"; filename=\"" - + filename.replace("\"", "%22") + "\"\r\nContent-Type: image/png\r\n\r\n"); - body.write(image); - writeUtf8(body, "\r\n--" + boundary + "--\r\n"); - return body.toByteArray(); - } - - private static void writeUtf8(ByteArrayOutputStream stream, String text) throws IOException { - stream.write(text.getBytes(StandardCharsets.UTF_8)); + 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, String contentType, HttpRequest.BodyPublisher body) @@ -271,13 +261,13 @@ private static Optional retryAfter(HttpResponse response) { return response.headers().firstValue("Retry-After") .map(Long::parseLong) .filter(seconds -> seconds >= 0) - .map(seconds -> seconds > MAX_RETRY_AFTER.getSeconds() ? MAX_RETRY_AFTER : Duration.ofSeconds(seconds)); + .map(seconds -> Duration.ofSeconds(Math.min(seconds, MAX_RETRY_AFTER.getSeconds()))); } catch (NumberFormatException e) { return Optional.empty(); } } - protected void sleep(Duration duration) throws InterruptedException { + void sleep(Duration duration) throws InterruptedException { Thread.sleep(duration.toMillis()); } @@ -293,9 +283,12 @@ 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")) { - boolean multipart = response.uri().getPath().endsWith(PathProvider.MULTIPART_PATH); throw new TestRunException(response.statusCode(), serverMessage(responseBody), multipart); } return gson.fromJson(responseBody, classOfT); diff --git a/src/test/java/io/visual_regression_tracker/sdk_java/MockHttpClient.java b/src/test/java/io/visual_regression_tracker/sdk_java/MockHttpClient.java index af71a54..888bb0a 100644 --- a/src/test/java/io/visual_regression_tracker/sdk_java/MockHttpClient.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/MockHttpClient.java @@ -106,7 +106,7 @@ public Optional sslSession() { @Override public URI uri() { - return URI.create("http://localhost/test-runs"); + return null; } @Override 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 f227789..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,25 +165,6 @@ public void shouldNameTheKeyOfAnInvalidValue() throws Exception { .build(); } - @Test - public void shouldReadSourceBranchFromEachSource() throws Exception { - VisualRegressionTrackerConfig fromBuilder = VisualRegressionTrackerConfig.builder() - .configFile(configFile(REQUIRED_FIELDS + "}")) - .sourceBranch("release") - .build(); - VisualRegressionTrackerConfig fromEnvironment = new EnvironmentVariables("VRT_SOURCEBRANCH", "release") - .execute(() -> VisualRegressionTrackerConfig.builder() - .configFile(configFile(REQUIRED_FIELDS + ", \"sourceBranch\": \"ignored\"}")) - .build()); - VisualRegressionTrackerConfig fromFile = VisualRegressionTrackerConfig.builder() - .configFile(configFile(REQUIRED_FIELDS + ", \"sourceBranch\": \"release\"}")) - .build(); - - assertThat(fromBuilder.getSourceBranch(), is("release")); - assertThat(fromEnvironment.getSourceBranch(), is("release")); - assertThat(fromFile.getSourceBranch(), is("release")); - } - @Test public void shouldKeepTheConstructorWithoutSourceBranch() { VisualRegressionTrackerConfig config = new VisualRegressionTrackerConfig( 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 index 5f3ba19..a434fba 100644 --- a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java @@ -147,7 +147,8 @@ public void payloadTooLargeOnBase64ShouldSuggestMultipart() { TestRunException exception = expectThrows(TestRunException.class, () -> vrt.track("Home page", "aW1hZ2U=")); - assertThat(exception.getMessage(), containsString("multipart")); + assertThat(exception.getMessage(), + is("Request failed with status 413: request entity too large. " + TestRunException.PAYLOAD_TOO_LARGE_HINT)); } private void enqueueStatus(TestRunStatus status) { 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 index b143a57..6c4734d 100644 --- a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java @@ -33,7 +33,7 @@ public void setup() { vrt = new VisualRegressionTracker(new VisualRegressionTrackerConfig( mockWebServer.url("/").toString(), "key", "project", "develop", null, false, 1)) { @Override - protected void sleep(Duration duration) { + void sleep(Duration duration) { waits.add(duration); } }; 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" } From ea574dd53a0cc27c3786af7cdb2d6e8e0e872346 Mon Sep 17 00:00:00 2001 From: Pavlo Strunkin Date: Mon, 2 Nov 2026 23:07:34 +0200 Subject: [PATCH 3/4] fix: reject a negative httpTimeoutInSeconds again, test unparseable Retry-After and Content-Length Co-Authored-By: Claude Opus 5.5 --- .../sdk_java/VisualRegressionTracker.java | 4 ++-- .../VisualRegressionTrackerMultipartTest.java | 1 + .../sdk_java/VisualRegressionTrackerRetryTest.java | 11 +++++++++++ .../sdk_java/VisualRegressionTrackerTest.java | 9 +++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) 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 78a73b5..2e3d128 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 @@ -143,7 +143,7 @@ public TestRunResult track(String name, Path image) throws IOException, Interrup private TestRunResult trackMultipart(String name, byte[] image, String filename, TestRunOptions testRunOptions) throws IOException, InterruptedException { - log.info("Tracking test run <{}> from {} with options <{}> for buildId <{}>", name, filename, testRunOptions, buildId); + 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(); @@ -237,7 +237,7 @@ private HttpResponse getResponse(METHOD method, String url, String conte .header(PROJECT_HEADER, configuration.getProject()) .header("Content-Type", contentType) .uri(URI.create(url)); - if (configuration.getHttpTimeoutInSeconds() > 0) { + if (configuration.getHttpTimeoutInSeconds() != 0) { requestBuilder.timeout(Duration.ofSeconds(configuration.getHttpTimeoutInSeconds())); } HttpRequest request = getRequest(method, body, requestBuilder); 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 index a434fba..35182a2 100644 --- a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerMultipartTest.java @@ -73,6 +73,7 @@ public void trackBytesShouldPostEveryOptionAsMultipartForm() throws Exception { 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))); 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 index 6c4734d..fde1c5a 100644 --- a/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java +++ b/src/test/java/io/visual_regression_tracker/sdk_java/VisualRegressionTrackerRetryTest.java @@ -100,6 +100,17 @@ public void shouldNotRetryOtherErrorsWithRetryAfter() { 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) 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 891e922..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 @@ -478,6 +478,15 @@ public void zeroHttpTimeoutShouldMeanNoLimit() throws IOException, InterruptedEx 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); From 63bd51566f3746e7d3b2ebeb2ddf3b96426e79a1 Mon Sep 17 00:00:00 2001 From: Pavlo Strunkin Date: Mon, 2 Nov 2026 23:10:07 +0200 Subject: [PATCH 4/4] fix: send Content-Length with the multipart body Newer JDKs send a body of unknown length chunked, which some proxies refuse. Co-Authored-By: Claude Opus 5.5 --- .../sdk_java/VisualRegressionTracker.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 2e3d128..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 @@ -147,7 +147,9 @@ private TestRunResult trackMultipart(String name, byte[] image, String filename, JsonObject fields = gson.toJsonTree(testRunRequest(name, testRunOptions).build()).getAsJsonObject(); String boundary = UUID.randomUUID().toString(); - HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.ofByteArrays(multipartBody(boundary, fields, image, filename)); + 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));